A local MQTT server for the Growatt Nexa 2000 balcony storage system: run it instead of the vendor cloud, and keep the device working on your own network.
A personal weekend project, built with heavy AI assistance. It runs against exactly one device — the author's — on a home network, and it is written to be honest about what has actually been observed rather than to be a product. Treat it accordingly. It is not affiliated with or endorsed by Growatt.
The device talks to it, and Home Assistant both shows it and drives it.
Working, verified against real hardware:
-
Is the MQTT server the device connects to. TLS on port 7006, MQTT 3.1.1, taking the device's identity from its CONNECT — the credentials it sends are its serial and a firmware constant, so they identify rather than authenticate. No separate broker to run: this is the design decision the rest follows from.
-
Decodes the protocol. Obfuscated legacy Growatt framing, CRC-16/MODBUS, the input register map for telemetry, the holding register map for settings, and the datalogger's own config space. Records the device replays from its internal archive after a reconnect are decoded and logged but never treated as current, since they can be over an hour old.
-
Serves everything over a control API on a Unix socket — telemetry, settings, identity, datalogger configuration — and accepts writes.
-
Writes settings with read-back confirmation, from an allowlist, because the device silently clamps out-of-range values rather than rejecting them and acknowledges a range write without saying what it stored.
-
Sets the device's clock, which is otherwise the vendor server's job.
-
Optionally relays to the vendor cloud, so the phone app keeps working — with a policy deciding how much authority the cloud keeps.
-
Records raw frames for later analysis, including the ones the relay policy refused.
-
Publishes to Home Assistant over your own broker, with MQTT autodiscovery. Sixty-odd entities per device, derived from the register maps rather than from a second list, and two availability topics — this program's own as a last will, the device's own as a telemetry watchdog — so a reading goes
unavailableinstead of flat-lining when the device drops off. Nothing publishes a substitute value, which is what keeps the Energy dashboard honest. -
Accepts commands from Home Assistant, through the same allowlist and the same read-back as the control API, so what appears in Home Assistant afterwards is what the device stored rather than what it was asked for.
Also unimplemented: retargeting the device's broker endpoint by writing its config registers, which would remove the need for the DNS override below. The protocol for it is understood; it stays unimplemented because a wrong value there has no remote recovery.
The Nexa 2000's datalogger speaks MQTT over TLS to a fixed cloud endpoint and performs no
certificate validation. So a local service can stand in for that endpoint with a DNS override —
point mqtt.growatt.com at the machine running Heliobridge — and no change to the device itself.
A destination NAT rule works equally well. Heliobridge generates its own certificate on first run.
With --cloud-relay it also dials the real endpoint and passes traffic both ways, so the vendor app
keeps working while everything is decoded locally.
One binary, no configuration file. Every option is a flag with a matching HELIOBRIDGE_*
environment variable; --help documents each one in full.
Clearing a variable is the same as leaving it out. HELIOBRIDGE_MQTT_URL= turns publishing off,
HELIOBRIDGE_RECORD_DIR= turns recording off, and a setting with a default falls back to it. The two
allowlists are the exception: empty is already their value, meaning admit everything.
The state directory holds the generated certificate, which the device does not verify and which is
regenerated when missing. Point HELIOBRIDGE_STATE_DIR somewhere durable to keep one across reboots.
| Variable | Default | What it does |
|---|---|---|
HELIOBRIDGE_LISTEN |
0.0.0.0:7006 |
Device-facing TLS listener |
HELIOBRIDGE_TLS_CERT / _KEY |
generated | Certificate presented to the device |
HELIOBRIDGE_STATE_DIR |
$TMPDIR/heliobridge |
Generated certificate and cached state |
HELIOBRIDGE_CONTROL_SOCKET |
off | Unix socket for the control API, mode 0600 |
HELIOBRIDGE_ALLOW_FROM |
any | Addresses and networks the device may connect from |
HELIOBRIDGE_ALLOW_DEVICES |
any | Device serials to serve |
HELIOBRIDGE_SLOTS |
1 |
How many of the nine schedule slots to expose |
HELIOBRIDGE_MQTT_URL |
off | Broker to publish to: mqtt://host[:port] or mqtts://host[:port] |
HELIOBRIDGE_MQTT_USER / _PASS |
(unset) | Broker credentials |
HELIOBRIDGE_MQTT_PASS_FILE |
(unset) | File holding the password, read at startup. Takes precedence over _PASS |
HELIOBRIDGE_MQTT_CLIENT_CERT / _KEY |
(unset) | Client certificate, for a broker that authenticates by one |
HELIOBRIDGE_MQTT_BASE |
heliobridge |
Root of this program's own topics |
HELIOBRIDGE_MQTT_DISCOVERY_PREFIX |
homeassistant |
Root Home Assistant watches for discovery |
HELIOBRIDGE_MQTT_INSTANCE |
the host name | Distinguishes this bridge from another on the same broker |
HELIOBRIDGE_ALLOW_WRITES |
true |
false publishes every setting as a read-only sensor and refuses every command |
HELIOBRIDGE_ALLOW_POWER_PLUS |
true |
false does the same for power_plus alone |
HELIOBRIDGE_OFFLINE_AFTER |
30 |
Seconds without telemetry before the device is reported absent |
HELIOBRIDGE_CLOUD_RELAY |
off | Relay to the vendor cloud |
HELIOBRIDGE_RELAY_MODE |
controls |
How much authority the cloud keeps |
HELIOBRIDGE_RELAY_ANSWERS |
cloud-only |
Which answers to earlier commands reach the cloud |
HELIOBRIDGE_RECORD_DIR |
off | Record raw frames for analysis |
HELIOBRIDGE_LOG |
info |
Tracing filter, per subsystem |
Both allowlists are empty by default, and empty admits everything — one device on an isolated VLAN needs neither. They are comma-separated:
HELIOBRIDGE_ALLOW_FROM=192.168.2.238,192.168.2.0/24,2001:db8::/32,fe80::/10
HELIOBRIDGE_ALLOW_DEVICES=0EXAMPLE00000001An address that is not allowed is dropped on accept, before the TLS handshake. A serial that is not
allowed is answered with a CONNACK refusal at connect, before the session registers — so it never reaches
the control API, never becomes a Home Assistant entity and never has a frame recorded.
Both lists say what is allowed, and nothing else is implicit. Listing only IPv4 does not deny IPv6, and
loopback is not admitted unless 127.0.0.1 or ::1 is listed. An entry that cannot be parsed is a startup
failure, because the failure mode of a mistyped list is a device that silently stops connecting.
Neither replaces network isolation. The protocol's credentials are the serial plus a fixed string, so they identify rather than authenticate, and the serial crosses a connection whose certificate the device does not verify — anyone positioned to capture one already has it.
A relative HELIOBRIDGE_MQTT_PASS_FILE is resolved inside $CREDENTIALS_DIRECTORY, which systemd sets
for a unit using LoadCredential=:
LoadCredential=mqtt-pass:/etc/heliobridge/mqtt.pass
Environment=HELIOBRIDGE_MQTT_PASS_FILE=mqtt-passAn absolute path is used as given. Trailing newlines are stripped. A file that cannot be read is a startup failure.
| Variable | Default | Effect |
|---|---|---|
TZ |
the host's zone | The zone the device's clock is set to |
SSL_CERT_FILE |
(unset) | A PEM bundle replacing the shipped trust anchors for outbound TLS |
SSL_CERT_DIR |
(unset) | A directory of them, same effect |
The device is sent local wall time, not UTC, so TZ sets the time the device runs on and the times its
schedule slots fire. Set it where the process is defined; a container defaults to UTC.
TZ=Europe/Berlin heliobridgeMozilla's roots ship in the binary. SSL_CERT_FILE or SSL_CERT_DIR replaces them entirely — use it to
trust a private authority, such as a broker with a self-signed certificate. Naming a store that holds no
usable certificate is a startup failure.
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt heliobridgeIn every mode the vendor app keeps displaying correctly. What differs is what it may change:
full— the app works as if this program were absent, including datalogger configuration. The cloud then also owns the clock, and could point the device away from here.controls(default) — the app still changes slots, output power, charge limits and the switches, but not the broker endpoint, DNS, timezone or clock, and nothing unrecognised. The vendor server was never observed sending anything outside the permitted set, so this costs no observed functionality.observer— the cloud sees everything and changes nothing. The right choice once settings are driven locally, since a second writer is only a way for two pictures to disagree.
Nothing the device sends is ever withheld from the cloud in any mode: a report cannot change the device's behaviour, and withholding one only makes the app's picture wrong — which matters, because the app writes whole register ranges back from that picture.
Worth remembering in every mode: "the cloud" is anyone who can reach the vendor broker knowing this serial.
Set HELIOBRIDGE_MQTT_URL and the device appears through MQTT autodiscovery. Entities are derived from
the register maps, so a register gaining a name gains an entity.
heliobridge/<serial>/state telemetry, JSON, one publish per cycle
heliobridge/<serial>/settings holding-register values, retained
heliobridge/<serial>/status connected, and when the last frame arrived — retained
heliobridge/<serial>/set commands, JSON {"slot1_output_power": 100}
heliobridge/<serial>/availability online | offline — the device
heliobridge/bridge/<instance>/availability online | offline — this program, as a last will
homeassistant/<component>/heliobridge/<serial>_<field>/config discovery, retained
Each entity reads one field out of the shared object with a value_template, so a telemetry cycle is one
publish rather than sixty. Discovery is retained and republished on every broker connection, which makes a
broker restart, a network blip and a first start the same case; an entity that leaves the catalogue is
withdrawn with an empty payload rather than left behind.
Two availability topics, listed by every reading with availability_mode: all. This program dying is a
last will, which the broker publishes for us. A device going away is something only this program can see,
so it says so itself — after HELIOBRIDGE_OFFLINE_AFTER seconds without a telemetry frame, since the
device's own MQTT keepalive is 420 s and a half-open connection would otherwise leave stale readings on a
dashboard for seven minutes.
Nothing publishes a substitute value. No zero, no repeat of the last reading: on a total_increasing energy
sensor a zero reads as a counter reset and the next real value is counted as a day's worth of new energy, and
a repeated value is a flat line indistinguishable from a real one. The entity goes unavailable and Home
Assistant records a gap. Two entities carry only this program's availability, so they keep working through
an outage and say how stale everything else is: Device connected and Last update.
Commands arrive as a JSON object on heliobridge/<serial>/set, naming a setting and a value — which is
what the discovery messages tell Home Assistant to send, and what mosquitto_pub can send by hand:
{"slot1_output_power": 100}
{"grid_power_allowed": 1}
{"slot1_work_mode": "smart_self_use"}
{"slot1_start_time": "23:59"}A command goes through the same allowlist and the same read-back as the control API. The value republished afterwards is what the device stored, which is not always what was asked: it clamps silently rather than rejecting, so asking for more than a setting's ceiling shows up as the lower figure. A value outside a register's documented range is refused before anything is sent, and a payload naming something unknown is logged with the reason rather than being coerced into a register. One bad field refuses the whole payload.
HELIOBRIDGE_ALLOW_WRITES=false publishes every setting as a read-only sensor and refuses every command;
HELIOBRIDGE_ALLOW_POWER_PLUS=false does the same for that one setting, which stays visible as a sensor.
Both close the entity and the command topic together, so a retained or hand-published command cannot reach
a control that was not offered.
For the Energy dashboard: pv_energy_total as solar production, battery_charge_energy_today and
battery_discharge_energy_today as the battery pair. The grid slots need a house meter — this device has no
meter between the house and the grid, so it cannot separate self-consumption from what crossed the boundary.
Several devices may share one broker: every device-facing topic and every unique_id carries the serial.
HELIOBRIDGE_MQTT_INSTANCE distinguishes two bridges on one broker, and appears in one topic only — this
program's own availability, where a shared name would make one bridge's shutdown mark another's entities
unavailable.
HTTP over the Unix socket, so curl --unix-socket is the whole client. Errors are
application/problem+json.
GET /healthz
GET /devices connected devices
GET /devices/{device} summary: model, firmware, endpoint, clock skew
GET /devices/{device}/identity the datalogger's self-report
GET /devices/{device}/telemetry every decoded input register
GET /devices/{device}/telemetry/{key}
GET /devices/{device}/settings cached settings
GET /devices/{device}/settings/{key}
PUT /devices/{device}/settings/{key} write, then read back to confirm
POST /devices/{device}/settings/{key}/read refresh from the device
GET /devices/{device}/config/{key} datalogger configuration
POST /devices/{device}/config/{key}/read
GET /devices/{device}/actions
POST /devices/{device}/actions/{key} restart the datalogger, clear its log
cargo build --release links against the build host's glibc, so the result will not start on a distribution
older than that host. The release targets are musl instead, which links statically: one binary per
architecture, running on any Linux of that architecture.
cargo install cargo-zigbuild
pip install ziglang # or Zig from ziglang.org, or a package manager
cargo zigbuild --release --target x86_64-unknown-linux-musl
cargo zigbuild --release --target aarch64-unknown-linux-muslBoth targets install with the toolchain, and the command is the same for either — including the host's own architecture, so there is one recipe rather than one per machine.
Zig is there because the crypto provider that rustls and rcgen pull in compiles C and assembly, which a
cross build needs a C toolchain for. Zig ships a complete one for every target and cargo-zigbuild puts it
where cargo expects a linker; the alternative is a separate C cross compiler per architecture, and nothing
packages one for aarch64-musl. Installed as a Python package, Zig has no zig executable, so
cargo-zigbuild finds it through python3 -m ziglang — put the environment holding it on PATH.
- Library plus thin binary. The protocol layer is pure
bytes → valueswith no I/O, so it is tested against recorded frames rather than against hardware. #![forbid(unsafe_code)], edition 2024, and lints that denyunwrap,expect, slice indexing and unchecked arithmetic in the library.- Vendor- and generation-neutral seams: the relay policy speaks in intents, and the Growatt generation-7 codec translates into them.
Writing to registers this device does not document is not safe. Vendor guidance is explicit that bypassing the AC charging controller's limits risks thermal runaway. Heliobridge restricts writes to an allowlist of settings the vendor app itself exposes, and that restriction is structural — an allowlist expressed as a type — rather than a runtime check.
The datalogger's config space is treated more cautiously still: the endpoint registers can be read but are not exposed for writing, because a wrong value there strands the device somewhere only Bluetooth can reach it.
Two existing projects with overlapping goals, both worth looking at first — either may suit you better than this one:
- GroBro — an MQTT bridge for Growatt NOAH and NEXA devices with Home Assistant autodiscovery, including local-only operation.
- nexa-mqtt — bridges the Nexa into Home Assistant through Growatt's cloud API. Different trade-off: it needs the vendor cloud and an account, but it needs nothing on your network.
Licensed under the Apache License, Version 2.0 — see LICENSE.
Copyright 2026 Simon Eisenmann. See NOTICE.