Skip to content

Repository files navigation

Heliobridge

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.

Status

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 unavailable instead 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.

How it works

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.

Configuration

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

Who may connect

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=0EXAMPLE00000001

An 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-pass

An absolute path is used as given. Trailing newlines are stripped. A file that cannot be read is a startup failure.

Standard variables

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 heliobridge

Mozilla'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 heliobridge

Relay modes

In 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.

Home Assistant

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.

Control API

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

Building for another machine

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-musl

Both 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.

Design

  • Library plus thin binary. The protocol layer is pure bytes → values with no I/O, so it is tested against recorded frames rather than against hardware.
  • #![forbid(unsafe_code)], edition 2024, and lints that deny unwrap, 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.

Safety

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.

Related projects

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.

Licence

Licensed under the Apache License, Version 2.0 — see LICENSE.

Copyright 2026 Simon Eisenmann. See NOTICE.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages