Real-time solar inverter data collector: Modbus TCP → MQTT.
Written in Go. Uses device register maps (JSON) for different inverter models. Focuses on live power flow (PV, grid, battery, load) + model info by default.
- Modbus TCP (simonvetter/modbus) with mandatory range merging for minimal reads (protects the inverter from too many small requests).
- Modern MQTT v5 client (eclipse-paho/paho.golang + autopaho) with auto-reconnect and Last Will (availability).
- Curated defaults for "live" data only (no flood of 60+ fault bits unless you ask via --poll).
- Rich JSON payload with
value,unit,labelfor each metric. - Config file (YAML/JSON) + env + CLI (CLI wins).
- Stdout poll summary logs every cycle.
- Self-contained (devices embedded).
- Graceful shutdown + reconnects.
# build the binary (standard Go layout)
go build -o ghrian-agent ./cmd/agent
# help
./ghrian-agent --help
# example (uses dev mqtt on localhost:1883)
./ghrian-agent \
--device-model solis_s5-eh1p5k-l \
--modbus 192.168.1.50 \
--mqtt-broker tcp://localhost:1883 \
--mqtt-topic ghrian/inverter/01With the dev compose (from this repo):
make dev-up
# in another terminal:
make dev-sub # easiest (execs into container, uses localhost)
# or manually: docker compose -f dev/compose.yml -p agent up -d --wait
# run agent (point modbus at your inverter or a simulator)Published to Docker Hub as
felipezavan/ghrian-agent (multi-arch:
amd64 + arm64). It's a tiny static image with no config file baked in — configure
it entirely with flags or env vars:
docker run --rm felipezavan/ghrian-agent \
--device-model solis_s5-eh1p5k-l \
--modbus 192.168.1.50:502 \
--mqtt-broker tcp://broker:1883 \
--mqtt-topic ghrian/inverter/01
# equivalently, via env vars:
docker run --rm \
-e DEVICE_MODEL=solis_s5-eh1p5k-l -e MODBUS_ADDR=192.168.1.50:502 \
-e MQTT_BROKER=tcp://broker:1883 -e MQTT_TOPIC=ghrian/inverter/01 \
felipezavan/ghrian-agentTags: X.Y.Z / latest from releases, edge tracks main.
The agent is the one piece that has to sit on your LAN next to the inverter, and
a Raspberry Pi is a natural home for it. The image is multi-arch, so the arm64
variant is pulled automatically — nothing special to do. This repo ships a
standalone compose.yml that runs only the agent (your broker
and server live elsewhere) and restarts it on boot:
git clone https://github.com/zavan/ghrian-agent
cd ghrian-agent
cp .env.example .env # set MODBUS_ADDR + MQTT_BROKER (see the comments)
docker compose up -d # pulls the arm64 image and starts pollingThat's the whole deploy. From then on:
docker compose logs -f # watch the per-cycle poll summary
docker compose pull && docker compose up -d # update to a newer image
docker compose down # stop itDon't want a git checkout on the Pi? The two files are all you need — copy
compose.yml and .env.example onto the Pi, rename the latter to .env, and
run docker compose up -d in that directory.
To run the whole stack (broker + server + agent) on a single host instead, use the
compose.ymlin the top-level ghrian repo — this one is deliberately agent-only.
All params can come from:
--config path/to/config.yaml(or auto-discovered: ./config.yaml, ~/.config/ghrian-agent/config.yaml, /etc/...)- Environment variables (MODBUS_ADDR, MQTT_BROKER, POLL=..., etc.)
- CLI flags (highest precedence)
Example config.yaml:
device_model: solis_s5-eh1p5k-l
modbus: 192.168.1.50:502
modbus_unit: 1
interval: 10
# poll: [] # omit or empty => curated live power flow + model defaults
mqtt:
broker: tcp://localhost:1883
topic: ghrian/inverter/01
user: ""
pass: ""
qos: 1 # at-least-once delivery (drop to 0 for fire-and-forget on a trusted LAN)
retain: true # keep the last reading on the broker for fresh consumersKey flags (see --help):
--device-model(required, matches internal/agent/devices/.json)--modbusip[:port] (default port 502)--modbus-unit(default 1)--interval(default 10)--pollname1,name2,... (comma list; overrides defaults)--mqtt-broker,--mqtt-topic,--mqtt-user,--mqtt-pass--config
Secrets: prefer the
MQTT_PASSenv var or a config file for the broker password. A--mqtt-passflag is visible to any user viaps/process listing and may land in shell history.
Each poll publishes a JSON message to --mqtt-topic where every metric is a
self-describing { value, unit, label } object (bitfields also emit
<name>_active: []string), plus a retained <topic>/availability (online/
offline). The agent sets online on connect, flips to offline after a few
consecutive poll failures or on graceful shutdown (a clean MQTT DISCONNECT
suppresses the Last Will, so the agent publishes it itself), and registers
offline as its LWT for crash/network-drop. Units and labels come straight from
the device JSON.
The full message format — topics, QoS/retain, bitfields, cumulative today_*
counters, and availability semantics — is the project-wide contract every module
agrees on:
See device.go:defaultLivePollNames. Includes PV power, AC power, battery V/I/SOC/power, load powers, grid port power, voltages/currents, frequency, temp, today's energies, status, model/serial.
Use --poll (or config) to request more (e.g. specific faults) or a subset.
- Drop your JSON file into
internal/agent/devices/your-model.json(follow the structure of the Solis one; register_start/end, function_code 0x02/0x04, data_type (sign is encoded here —S16/S32= signed), scale, unit, label, bit_definitions/enum_values as needed). If the model has LV/HV-split bitfields, add anlv_hvblock (model_register, battery_model_register, lv_low_bytes, hv_low_bytes) so the LV/HV rule stays out of Go. The embed directive will pick it up. - Optionally update the curated default list in device.go if you want it included for new models.
- Update README if publishing the model.
- The JSON was generated from the accompanying PDF protocol doc.
go test ./...(covers load, compute with sample raws, range merge, config precedence).- Mock device: the tests +
poller_test.go(add your own) use the modbus server from the library to simulate an inverter serving the live registers. - MQTT dev broker + monitoring:
make dev-upthenmake dev-sub(recommended — pins project name to match your "agent" UI/workspace project, waits for healthy via healthcheck, usesexec+localhostinside the container; no local client needed). Or manually (be consistent):docker compose -f dev/compose.yml -p agent up -d --waitthendocker compose -f dev/compose.yml -p agent exec -T mosquitto mosquitto_sub -h localhost -p 1883 -t 'ghrian/#' -v(or plainmosquitto_subif you have the CLI installed locally). - To test range merging / device friendliness: the merge logic is exercised in tests; it groups per FC and spans min-to-max (gaps filled) into as few <=50-register reads as possible (per PDF inter-frame guidance of max 50 regs / 100 bytes). E.g. sparse 33001, 33010, 33015 → single block read 33001-33015.
- Poll logging goes to stdout on every cycle (visible when running the binary).
This project is licensed under the MIT License - see the LICENSE file for details.
Greenfield project for ghrian. Read-only. Use at your own risk with real inverters (start with low interval, monitor).
Cross-reference the PDF in internal/agent/devices/ (or the original source) for exact semantics, appendices (bitfields, models, etc.).
Pull requests welcome for more models / features.