Skip to main content
Glama
sigenergy-mcp

sigenergy-mcp

by aydho
README.md
# sigenergy-mcp

An [MCP](https://modelcontextprotocol.io) server for monitoring and controlling a
**Sigenergy** energy system (SigenStor, Sigen hybrid inverters) locally over
**Modbus TCP** — no cloud account needed.

It exposes live PV, battery, grid and load data, inverter details and Sigen EV charger
status as MCP tools, and (optionally) control tools for remote EMS mode, battery
charge/discharge limits, grid export/import limits, SOC limits and EV charging.

## Prerequisites

1. **Enable Modbus TCP** on your Sigenergy system. In the mySigen app (installer
   account) go to the device → *Settings* → *Communication* → *Modbus TCP Server*
   and turn it on. Your installer can do this for you if you don't have access.
2. Give the system a **static IP / DHCP reservation** on your LAN.
3. Note the Modbus **device ids**: the plant (whole system, aggregated by the EMS)
   is `247`; inverters start at `1` (multiple inverters are `1`, `2`, …). A Sigen
   **AC charger** normally takes id `1`, so the inverter is moved to another id
   (e.g. `2`). A **DC charger** answers on its inverter's id. If you're unsure, run
   the `discover_devices` tool once — it reports the ids and the settings to use.
4. Python 3.10+.

## Install

```bash
pip install git+https://github.com/aydho/sigenergy-mcp
# or, from a checkout
pip install -e .
```

Or run without installing using [uv](https://docs.astral.sh/uv/):

```bash
uvx --from git+https://github.com/aydho/sigenergy-mcp sigenergy-mcp --host 192.168.1.50
```

## Configuration

| Environment variable      | CLI flag          | Default | Description                                 |
|---------------------------|-------------------|---------|---------------------------------------------|
| `SIGENERGY_HOST`          | `--host`          | —       | IP address of the Sigenergy system (required) |
| `SIGENERGY_PORT`          | `--port`          | `502`   | Modbus TCP port                              |
| `SIGENERGY_PLANT_ID`      | `--plant-id`      | `247`   | Plant device id                              |
| `SIGENERGY_INVERTER_IDS`  | `--inverter-ids`  | `1`     | Comma-separated inverter device ids          |
| `SIGENERGY_AC_CHARGER_IDS`| `--ac-charger-ids`| —       | AC charger (EVAC) device ids, usually `1`    |
| `SIGENERGY_DC_CHARGER_IDS`| `--dc-charger-ids`| —       | DC charger (EVDC) ids = the inverter's id    |
| `SIGENERGY_ALLOW_WRITES`  | `--allow-writes`  | `false` | Enable control (write) tools                 |
| `SIGENERGY_AUDIT_LOG`     | `--audit-log`     | —       | File to append a JSON line to for every write |
| `SIGENERGY_ACCESS_TEAM_DOMAIN` |             | —       | Cloudflare Access team (`acme` or `acme.cloudflareaccess.com`); enables Access JWT validation on HTTP |
| `SIGENERGY_ACCESS_AUD`    |                   | —       | Cloudflare Access application **AUD** tag (required with the team domain) |
| `SIGENERGY_HTTP_ALLOWED_HOSTS` |              | —       | Allowed `Host` headers for HTTP, e.g. `pi.local:8000,192.168.1.20:8000` |
| `SIGENERGY_TIMEOUT`       |                   | `10`    | Modbus request timeout, seconds              |
| `SIGENERGY_RETRIES`       |                   | `1`     | Retries for a read that gets no response     |
| `SIGENERGY_MIN_REQUEST_INTERVAL` |            | `1.0`   | Minimum seconds between Modbus requests      |
| `SIGENERGY_HISTORY_DB`    |                   | `~/.local/share/sigenergy-mcp/history.db` | SQLite file for recorded history (HTTP transport only); empty to disable |
| `SIGENERGY_HISTORY_INTERVAL` |                | `60`    | Seconds between history samples; `0` to disable |
| `SIGENERGY_TIMEZONE`      |                   | system  | IANA time zone for daily history buckets, e.g. `Australia/Melbourne` |
| `SIGENERGY_LOG_LEVEL`     |                   | `INFO`    | Server log level (`INFO` logs each tool call and history poll) |

Writes are **off by default**: the control tools aren't even listed to the client until you opt in.

### History

When run with the HTTP transport, the server samples the plant once a minute (3 Modbus requests) into a local
SQLite database:

| Table    | Contents                                              | Kept     |
|----------|-------------------------------------------------------|----------|
| `raw`    | Every sample                                          | 30 days  |
| `hourly` | Each completed hour: avg/min/max per gauge, kWh per counter | 90 days  |
| `daily`  | Each completed local day, same columns                | Forever  |

Gauges are PV, battery, grid and load power and battery SOC; counters are the lifetime PV, load, battery
charge/discharge, grid import/export and EV charger energy, reported as the kWh in each interval. The
`get_history` tool reads it, choosing a resolution to suit the range and filling in the hour or day in progress.

History is only recorded by a long-lived HTTP server, never with the stdio transport: a stdio server is started
for each client session, so recording there would poll the system once per open session. Run a single HTTP
server per system. A stdio server's `get_history` still reads the database that HTTP server writes.

### Request pacing

The Sigenergy Modbus protocol (section 4.2, "Interaction timeout") sets a **minimum request period of
1000 ms**, so the server sends one request at a time and waits at least `SIGENERGY_MIN_REQUEST_INTERVAL`
seconds between them, including for retries and `discover_devices`. Nearby registers are read in a single
request to keep the count down, but a tool still takes roughly one second per request it needs:
`get_plant_status` takes about 3 s, and `discover_devices` about 2-4 s per id scanned. Raise the interval on
a slow or busy link; lowering it below 1 s goes against the protocol.

### Claude Desktop / Claude Code

```json
{
  "mcpServers": {
    "sigenergy": {
      "command": "sigenergy-mcp",
      "env": {
        "SIGENERGY_HOST": "192.168.1.50",
        "SIGENERGY_INVERTER_IDS": "2",
        "SIGENERGY_AC_CHARGER_IDS": "1",
        "SIGENERGY_ALLOW_WRITES": "false"
      }
    }
  }
}
```

With Claude Code:

```bash
claude mcp add sigenergy -e SIGENERGY_HOST=192.168.1.50 -- sigenergy-mcp
```

### HTTP transport

To run it as a long-lived network service (e.g. on a box next to the inverter), bind loopback and
put an authenticating proxy in front — this is how the [Mac mini deploy](#deploy-to-the-mac-mini)
works, with [Cloudflare Access](#authentication-cloudflare-access) terminating auth:

```bash
export SIGENERGY_HTTP_ALLOWED_HOSTS=pi.local:8000
sigenergy-mcp --host 192.168.1.50 --transport streamable-http --http-host 127.0.0.1 --http-port 8000
# MCP endpoint: http://127.0.0.1:8000/mcp  (reached through the proxy/tunnel)
```

The server **refuses to listen on a non-loopback address** unless Cloudflare Access JWT validation is
configured (`SIGENERGY_ACCESS_TEAM_DOMAIN` + `SIGENERGY_ACCESS_AUD`), or you pass
`--allow-unauthenticated` (only when a different authenticating proxy sits in front).

The HTTP app also answers `GET /health` with `200 ok`, before any auth — for deploy and
load-balancer health checks.

#### Authentication (Cloudflare Access)

The HTTP transport has no token of its own; it trusts [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/secure-mcp-servers/)
to authenticate clients at the edge. Set both of these and every request must carry a valid Access JWT
(the `Cf-Access-Jwt-Assertion` header Access injects, or the `CF_Authorization` cookie):

```bash
export SIGENERGY_ACCESS_TEAM_DOMAIN=acme            # your <team>.cloudflareaccess.com
export SIGENERGY_ACCESS_AUD=<application-AUD-tag>   # from the Access application
```

The server verifies the JWT's signature against your team's JWKS (`https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`)
and checks its issuer and audience, so the origin only trusts requests that came through Access. Enable
**Managed OAuth** on the Access application and clients (Claude, MCP Inspector, …) authenticate with an
interactive OAuth login — no static token or custom headers. `GET /health` stays open for health checks.

### Deploy to the Mac mini

Pushing to `main` deploys to the Mac mini automatically, via the self-hosted GitHub Actions
runner (labels `self-hosted, mini`) and [`.github/workflows/mac-mini.yml`](.github/workflows/mac-mini.yml) —
the same pattern as the `shopping-agent`, `agentic` and `claude-pro-proxy` services. Each push
rsyncs the repo to `/srv/sigenergy-mcp`, builds/updates a venv, `pip install -e .`, restarts the
service and health-checks `GET /health`. `workflow_dispatch` offers `deploy` / `restart` / `logs`.

The service listens on **8003** (8000/8001/8002/8080 are taken) and binds `127.0.0.1`; it's
reached through the mini's cloudflared tunnel, so authentication is enforced at the edge rather
than by the app. History recording is on (the server samples once a minute, see [History](#history)).

Two files in [`deploy/`](deploy/) are the version-controlled reference:

* [`deploy/sigenergy-mcp.service`](deploy/sigenergy-mcp.service) — the systemd unit. Install it by
  hand once (the runner isn't given sudo to write `/etc/systemd/system`):

  ```bash
  sudo cp /srv/sigenergy-mcp/deploy/sigenergy-mcp.service /etc/systemd/system/
  sudo systemctl daemon-reload && sudo systemctl enable --now sigenergy-mcp
  ```
* [`deploy/sigenergy-mcp.env`](deploy/sigenergy-mcp.env) — just the `PORT`, rsynced on every deploy.

Connection settings and secrets go in `/etc/sigenergy-mcp/env` (untracked, `root:andrewsaunders`
`0640`), populated once by hand:

```bash
SIGENERGY_HOST=sigenstor.vista.aydho.com
SIGENERGY_TIMEZONE=Australia/Melbourne
SIGENERGY_HISTORY_DB=/srv/sigenergy-mcp/data/history.db
# Cloudflare Access JWT validation (see below). The server validates every request's Access JWT.
SIGENERGY_ACCESS_TEAM_DOMAIN=acme
SIGENERGY_ACCESS_AUD=<application-AUD-tag>
SIGENERGY_HTTP_ALLOWED_HOSTS=sigenergy.aydho.com
```

#### Expose it with cloudflared + Cloudflare Access (GitHub)

Add an ingress rule to the mini's `/etc/cloudflared/config.yml` (above the `http_status:404`
catch-all) and restart cloudflared:

```yaml
  - hostname: sigenergy.aydho.com
    service: http://localhost:8003
```

```bash
cloudflared tunnel route dns 85dac53c-cdd5-4122-8fc5-47c0736130c8 sigenergy.aydho.com
sudo systemctl restart cloudflared
```

Then protect `sigenergy.aydho.com` in the Cloudflare **Zero Trust → Access controls** dashboard:

1. **Add the application** and attach your identity provider. The purpose-built path is
   **AI controls → MCP servers → Add an MCP server** (HTTP URL `https://sigenergy.aydho.com/mcp`);
   a self-hosted application for the hostname works too.
2. **Add an Allow policy** that includes your **GitHub** identity (your account/email). This is the
   interactive browser sign-in.
3. **Enable Managed OAuth** on the application (**⋯ → Edit → Advanced settings → Managed OAuth**, with
   dynamic client registration on). Access then runs the OAuth login that MCP clients like Claude need —
   no static token or custom headers. Managed OAuth forwards a signed JWT to the origin, which this
   server validates via `SIGENERGY_ACCESS_TEAM_DOMAIN` + `SIGENERGY_ACCESS_AUD` (copy the **AUD** tag
   from the application's settings).

Add it in Claude (chat: **Settings → Connectors → Add custom connector**; or the CLI below) with just
the URL — the OAuth flow handles sign-in:

```bash
claude mcp add --transport http sigenergy https://sigenergy.aydho.com/mcp
```

For a non-interactive CLI client you can instead create a **service token** (**Access → Service Auth**),
add a **Service Auth** policy that includes it, and pass its two headers — Access still forwards a valid
JWT that the server validates:

```bash
claude mcp add --transport http sigenergy https://sigenergy.aydho.com/mcp \
  --header "CF-Access-Client-Id: <id>.access" \
  --header "CF-Access-Client-Secret: <secret>"
```

## Security

**Modbus TCP itself has no authentication or encryption.** Anything that can reach the
Sigenergy system on port 502 can read every register and change its settings; the device id
is just an address, not a secret. This server can't change that, so:

* **Never port-forward 502** to the inverter. Keep it on your LAN, ideally on its own
  VLAN/IoT network with firewall rules so only the machine running this server (or Home
  Assistant) can reach port 502.
* `SIGENERGY_ALLOW_WRITES` only controls what *this server* will do. It doesn't stop other
  devices on your network writing to the inverter directly.

What the server does to protect its own interface:

* **Read-only by default.** Without `SIGENERGY_ALLOW_WRITES=true` the control tools are
  removed from the server, so a client can't see or call them. Every write is still range- and
  enum-checked. Tools that write several registers report which writes already took
  effect if a later one fails. Plant and inverter start/stop can't be written at all.
* **Audit log.** Set `SIGENERGY_AUDIT_LOG=~/sigenergy-audit.jsonl` to record every write
  attempt (time, device, register, previous value, new value, success or error), so you can
  see and undo what was changed.
* **HTTP transport.** It binds to `127.0.0.1` by default, with Host/Origin checks against DNS
  rebinding, and refuses a non-loopback bind unless authentication is configured. Authentication is
  delegated to **Cloudflare Access**: set `SIGENERGY_ACCESS_TEAM_DOMAIN` + `SIGENERGY_ACCESS_AUD` and
  every request must carry a valid Access JWT (signature checked against your team's JWKS, plus issuer
  and audience), so the origin only trusts traffic that came through Access. Set
  `SIGENERGY_HTTP_ALLOWED_HOSTS` to the public hostname to keep the Host/Origin checks too.
* **TLS.** The server speaks plain HTTP. For HTTPS or access from outside your home, put it
  behind [Tailscale](https://tailscale.com) / WireGuard or a reverse proxy such as Caddy rather
  than opening a port.

## Tools

**Monitoring (read-only)**

| Tool | Description |
|------|-------------|
| `get_plant_status` | PV, battery, grid and load power, SOC, EMS mode, running state, decoded active alarms |
| `get_battery_status` | SOC, SOH, power, rated/available energy, power limits, cut-off SOCs |
| `get_grid_status` | Grid power (total and per phase), on/off-grid status |
| `get_energy_totals` | kWh counters: PV today/yesterday/lifetime, load, battery charge/discharge, grid import/export |
| `get_inverter_status` | Per-inverter power, battery, energy counters, temperatures, AC phases, PV strings |
| `get_inverter_info` | Model, serial number, firmware, ratings |
| `get_ev_charger_status` | AC charger state (no vehicle / connected / charging…), power, energy, alarms; DC charger state, power, vehicle SOC, session energy |
| `get_history` | Recorded history from the local database: raw, hourly or daily rows plus kWh totals |
| `discover_devices` | Scan device ids 1..10 (or `max_device_id`) to find the plant, inverters and EV chargers, with suggested settings |
| `get_control_settings` | Current remote EMS settings, power limits and SOC limits |
| `list_registers` | All named registers with address, type, unit and gain |
| `read_registers` | Read named registers |
| `read_raw_registers` | Read any address (for registers not in the map) |

**Control (requires `SIGENERGY_ALLOW_WRITES=true`)**

| Tool | Description |
|------|-------------|
| `set_remote_ems_mode` | Enable remote EMS and set mode (standby, self-consumption, command charge/discharge…) |
| `set_remote_ems_enabled` | Enable/disable remote EMS (disable to hand control back to the built-in EMS) |
| `set_battery_power_limits` | Max charge / discharge power (kW) |
| `set_grid_limits` | Max grid export / import (kW) |
| `set_pv_power_limit` | Limit PV output (kW) |
| `set_soc_limits` | Backup SOC, charge cut-off and discharge cut-off (%) |
| `start_ev_charging` / `stop_ev_charging` | Start or stop charging on the AC or DC charger |
| `set_ac_charger_current` | AC charging current (A), checked against the charger's rated and breaker current |
| `set_dc_charger_power_limits` | DC charger max charge / discharge (V2G) power (kW) |
| `write_register` | Write any writable register in the map by name (except start/stop registers) |

### Sign conventions

* `grid_active_power` > 0 = importing from the grid, < 0 = exporting.
* `ess_power` > 0 = battery charging, < 0 = discharging.
* `load_power` is read from the system's load register (30282) on newer firmware, otherwise derived as
  plant AC output + grid power + third-party PV − AC charger power (so it excludes EV charging).

### Example: force-charge the battery from the grid at 5 kW

1. `set_remote_ems_mode(mode=3)` — command charging (grid first)
2. `set_battery_power_limits(max_charge_kw=5)`
3. Later, `set_remote_ems_enabled(false)` to return to normal operation.

## Register map

The register map lives in [`src/sigenergy_mcp/registers.py`](src/sigenergy_mcp/registers.py)
and follows the Sigenergy Modbus Protocol (V2.x). Available registers can vary with
firmware version; registers the device rejects are reported per-register in an
`errors` field instead of failing the whole call. Registers that answer "illegal data
address" are remembered per device and skipped on later calls. Use `read_raw_registers`
for anything not yet mapped.

Addresses, types and gains are cross-checked against
[TypQxQ/Sigenergy-Local-Modbus](https://github.com/TypQxQ/Sigenergy-Local-Modbus), the
field-tested Home Assistant integration; alarm bit descriptions are adapted from it (MIT).

## Development

```bash
pip install -e '.[dev]'
pytest
```

The tests run the MCP tools end-to-end against an in-process pymodbus simulator.

## Disclaimer

Not affiliated with Sigenergy. Writing Modbus registers changes how your system
operates — use the control tools at your own risk and check settings in the
mySigen app afterwards.

TDQS

A3.8/5.0

Scored across 12 tools

Disambiguation4/5

Each tool targets a distinct subsystem (plant, grid, battery, inverter, EV charger, energy totals, registers), so boundaries are mostly clear. The only mild overlap is that get_plant_status summarizes data also exposed by the individual status tools, but descriptions clarify the live-overview vs subsystem-detail distinction.

Naming Consistency5/5

Names follow a consistent verb_noun snake_case pattern (get_*_status, get_*_info, list_registers, read_registers, read_raw_registers, discover_devices). The verbs (get/list/read/discover) map predictably to action type.

Tool Count5/5

12 tools is well within the sweet spot for a monitoring/inspection domain, and each earns its place by covering a distinct device or data access layer.

Completeness4/5

The read surface is thorough: live status, static info, energy counters, settings, named-register and raw-register access, device discovery, and EV charging. The notable gap is write/control operations (e.g. setting power or SOC limits) implied by get_control_settings, though read_raw_registers partially mitigates unmapped reads.