Skip to content

Repository files navigation

Heliobridge

CI Crates.io Container License

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. The hourly settings snapshot is taken too, which is how a change made in the vendor app becomes visible without reconnecting.
  • 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. Seventy 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.
  • Supplies the smart meter reading that smart self-use needs, written straight into the inverter's holding registers. The device's own supported meters reach the same registers by polling a Shelly over the LAN, by Modbus, by a LoRa radio, or — for the vendor's documented integration — from the meter manufacturer's cloud by way of Growatt's. Writing the figure directly needs none of that: any source Home Assistant can read becomes usable, and no account anywhere is involved.
  • Reads and publishes the datalogger's own configuration — its network settings, its endpoint, its signal strength and the interval it reports on — and identifies the product from the type code the device reports, so a device page names the model and one assembled firmware version rather than three register values.
  • Notices the firmware the vendor's cloud advertises, logs the URL, and can keep the image. Nothing installs it; the campaign is refused either way.

Retargeting the device's broker endpoint by writing its config registers is deliberately unimplemented, though it would remove the need for the DNS override below. The protocol for it is understood; a wrong value there has no remote recovery.

How fast and how faithfully the device follows a command — latency for both ways of setting the output, what each is accurate to, and what a loop holding your grid connection near zero actually needs — is measured in docs/output-response.md.

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_RELAY_ACCESSORY_PAIRING false Let the cloud write the accessory list, and nothing else in the config space
HELIOBRIDGE_FIRMWARE_DIR off Keep firmware the cloud advertises here
HELIOBRIDGE_FETCH_FIRMWARE false Download the advertised image, rather than only logging its URL
HELIOBRIDGE_FIRMWARE_MAX_BYTES 16777216 Cap on a single firmware download
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.

One exception, off by default. HELIOBRIDGE_RELAY_ACCESSORY_PAIRING=true lets a cloud write reach the accessory list — and only that register — while controls goes on refusing every other configuration write. It exists because enrolling a meter through the vendor's own application is a three-stage conversation between the cloud and the device, and in controls mode the app's first command is refused and it reports a failed search.

Turn it on to pair a meter from the app, and leave it off otherwise: it is the one hole in the configuration-write policy, and the register it opens is the one that decides which accessory this device polls. The enrolment routes below do the same job without it.

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. The commonest reason for a device to go away is that it has powered itself down for the night — docs/sleep.md has the conditions that decide when, what it costs, and how to meet them deliberately.

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"}
{"supplied_meter_reading": -250}
{"withdraw_meter_reading": 1}

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 meter at the boundary, which this device does not have: it cannot separate self-consumption from what crossed it. Supplying a reading (below) does not change that — whatever measures the boundary is already the better source for those slots.

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.

Smart self-use

Work mode 2 regulates the inverter's output from a smart meter's reading of the grid connection. The device accepts that reading written into four holding registers, so it needs no meter of its own:

$ curl --unix-socket /run/heliobridge.sock -X PUT \
    -H 'content-type: application/json' -d '{"watts": 250}' \
    "http://local/devices/$SERIAL/meter-reading"

Positive is importing, negative is exporting. DELETE on the same path withdraws it. From Home Assistant the same two operations are the Supplied meter reading number and the Withdraw meter reading button.

Every write is a fresh submission, not a stored setting — which the number does not look like, and one consequence is worth knowing. Setting it from an automation or from Developer tools → Actions submits the figure every time, whether or not it differs from the last. Typing into the box does not: the frontend keeps the value it last sent and will not re-submit an unchanged one, so the same reading cannot be supplied twice by hand that way. To send a figure again, call the action rather than retyping it:

action: number.set_value
target:
  entity_id: number.nexa_2000_0example00000001_supplied_meter_reading
data:
  value: -250

or publish to the command topic directly with mqtt.publish, which is also what an automation should do if it is already computing the figure.

The reading is an error signal, not a target. For each new reading the device adjusts its own output by approximately that amount:

new_output ≈ old_output + 0.75 × reading

The factor was measured at 0.68–0.81 for readings of 100–600 W, and closer to one-for-one at 10–20 W. It matters only if you supply a figure and expect it as an output: with a real meter the shortfall is reported back on the next reading and corrected then. Supply what a meter at the grid connection would read — household load − ac_output_power — and the output converges on covering the house and keeps tracking it. Supply a fixed target instead and the output walks in one direction until the battery limits it.

Supplying the same figure again does nothing. The device acts on the reading changing; a repeat keeps it from expiring but moves nothing. docs/output-response.md has the measured latencies and what happens when readings stop — which is not what most people expect.

A reading expires after about two minutes, and nothing here refreshes it. Whatever holds the measurement writes it again inside that window; stop writing and the device drops the reading and behaves as though no meter were present.

Two entities report what the device makes of it. meter_connected says whether it currently holds a reading at all — the only way to distinguish a genuine 0 W reading, meaning the grid is balanced, from no meter, since both read 0 W. meter_active_power carries the reading it holds, and goes unavailable when it holds none rather than reading a misleading zero.

Two behaviours worth expecting. Allow three minutes after selecting the mode before the output follows — the vendor's own instructions say the same, and the first reading or two are ignored. And while a slot is in work mode 2 the device ignores that slot's slot{n}_output_power; the entity is published unavailable to say so, because the register still stores and reads back whatever is written to it.

⚠ Write a figure you have measured. The device acts on the reading without checking it against anything it measures itself, so a wrong one is obeyed — an import that no load justifies will discharge the battery to serve a load that is not there.

Enrolling an accessory on the local network

A meter on the local network is searched for by mDNS, chosen from what answered, and then polled by address. Three stages, three calls:

$ SERIAL=0EXAMPLE00000001
$ SOCK="--unix-socket /run/heliobridge.sock"

$ curl -N $SOCK -X POST -H 'content-type: application/json' \
    -d '{"model":"shelly-pro-3em"}' \
    "http://local/devices/$SERIAL/accessories/network/discovered/search"
{"serial":"187723572702975","mac":"aa:bb:cc:dd:ee:ff"}
{"found":1,"duration_seconds":60}

$ curl $SOCK -X POST -H 'content-type: application/json' \
    -d '{"serial":"187723572702975"}' \
    "http://local/devices/$SERIAL/accessories/network/discovered"
{"serial":"187723572702975","mac":"aa:bb:cc:dd:ee:ff","access":0,"state":"paired",
 "state_code":3,"address":"192.168.2.212","in_use":true,"waited_seconds":9}

$ curl $SOCK "http://local/devices/$SERIAL/accessories"
{"accessories":[{"transport":"network","kind":"discovered","entry":111,"mode":7,
  "name":"187723572702975","state":"paired","state_code":3,
  "serial":"187723572702975","mac":"aa:bb:cc:dd:ee:ff","address":"192.168.2.212",
  "in_use":true,"manufacturer":"shelly","model":"SPEM-003CEBEU","access":0,
  "communicating":true}]}

The search streams one line per accessory as the device reports it, then a summary, and runs for the device's own sixty seconds. Start a second search while one is running and it joins that one rather than restarting it. {"model": …} resolves to an mDNS service and the device's accessory type; name them directly with {"service":"_http._tcp.","type":2} for a model this build does not list.

The serial the confirm takes is the one the search reported. One the device never found is not refused here — it simply changes nothing, and the reply shows the entry unchanged and in_use null.

access decides whether the device uses the reading. It defaults to 0, which puts the meter in service. Pass 1 and the device polls the accessory and reports its figures while its own meter_active_power and meter_connected stay at zero. Both look identical in the accessory list, so in_use in the replies above is read from telemetry, not from the list.

entry is how a record is addressed. It is the device's own number for it, not something a caller chooses — every search sends 111 and the device puts the entry where it likes — so it is read from the listing and used:

$ curl $SOCK "http://local/devices/$SERIAL/accessories/network/discovered/111"
{"transport":"network","kind":"discovered","entry":111,"mode":7,"state":"paired",…}

$ curl $SOCK -X DELETE "http://local/devices/$SERIAL/accessories/network/discovered/111"
{"accessories":[{"transport":"network","kind":"discovered","entry":111,"mode":7,
  "name":"187723572702975","state":"deleted","state_code":5,
  "serial":"187723572702975","mac":"aa:bb:cc:dd:ee:ff","address":"192.168.2.212",
  "in_use":null,"manufacturer":null,"model":null,"access":null,"communicating":null}],
 "detail":"a delete tombstones the entry rather than removing it; a later search revives it in place, keeping its number"}

The serial and the MAC are accepted wherever either appears.

⚠ A search is refused while an accessory is paired, and that refusal matters. Searching over a live entry does not restart it: the device makes a second entry, whose search finds nothing, and whose presence stops the paired accessory being polled until it is deleted. Replacing one is {"model":…,"replace":true}, which deletes first.

Delete and re-enrol without a restart in between, and keep the gap short: the accessory list rides in the connect-time report the vendor's cloud reads, so a reconnect inside that window tells it the accessory is gone. Whether that has any lasting effect on the vendor application is unestablished — its meter display comes and goes on its own — but there is no reason to send it a state that is about to be untrue.

The routes read accessories/<transport>/<how it was acquired>/. discovered is the one found by mDNS and enrolled above. A device can hold others that a server reaches by an address it supplies — several can coexist — and those appear in GET /accessories as kind: "dialled". Nothing here searches for, enrols or removes them, and accessories/network/dialled/ is reserved for when something does. The transport alone would not have separated the two, since both are on the local network and share one register.

Accessories the vendor binds through its own cloud — its smart plugs, of which there may be several — appear in neither list, so an empty reply does not mean nothing is attached.

A paired accessory survives a datalogger restart and the device resumes polling it unasked; nothing here has to re-assert it.

⚠ The accessory has to be on the device's own network segment. mDNS is link-local, so a responder one subnet away is never found, and wireless client isolation defeats the search with no error anywhere.

Enrolment is not exposed to Home Assistant: it needs a person to choose from a list. The result is — accessory_list as a diagnostic, with meter_connected beside it.

Pairing an accessory on the LoRa radio

The device has a LoRa radio, on its power controller rather than the datalogger. An accessory that arrives over it is not given an address — it is adopted, during a window this opens:

$ curl --unix-socket /run/heliobridge.sock -X POST \
    "http://local/devices/$SERIAL/accessories/lora/pair"

From Home Assistant it is the Pair a LoRa accessory button.

Then put the accessory into its own pairing state, however it is done for that accessory. The window closes on its own and the register clears itself, so nothing has to be turned off afterwards and nothing is left open by a caller that forgets.

Which accessory gets adopted is decided by the accessory. The command carries no type and no serial, so this cannot pair a named device: whichever one is asking to be paired at that moment is the one that ends up paired.

An accessory on the local network is enrolled the other way, in the three stages above.

⚠ While the window is open the device will adopt an accessory that asks to be adopted, and nothing in the protocol authenticates either side. Press it when you are standing next to the hardware, not on a schedule.

Firmware the cloud advertises

The vendor's cloud advertises a firmware update by writing a URL into datalogger configuration register 80, about once an hour until the device installs it. The relay policy refuses cloud writes to the configuration space, so it never reaches the device.

This needs HELIOBRIDGE_CLOUD_RELAY. An advertisement arrives on the relay's cloud-to-device path; without a relay the device never hears from the vendor's cloud through this program, so there is nothing to notice and nothing to fetch.

With a relay, the advertisement is logged in full whether or not anything is kept, URL included:

the cloud advertised a firmware update source="configuration register 80"
  url=http://cdn.growatt.com/update/device/GB/manualUpgrade/…/WIFI/4.0.2.6.bin
  file=WIFI-4.0.2.6.bin refused=true fetch=false

HELIOBRIDGE_FIRMWARE_DIR keeps the image as well, and HELIOBRIDGE_FETCH_FIRMWARE=true downloads it. Fetching is off by default: an advertisement is traffic that arrives anyway, while downloading reaches out to a vendor host. An image already on disk is left alone, so an hourly campaign costs one download. The transfer is capped, is written under a temporary name and renamed once complete, and its SHA-256 is logged so the file can be compared with an image already held.

The request presents the same user agent and cache directive the datalogger's own firmware sends, and nothing else — this program does not announce itself to the vendor's CDN.

Nothing installs firmware. The image is stored and that is all.

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
PUT    /devices/{device}/config/{key}        write one config register
POST   /devices/{device}/config/{key}/read
POST   /devices/{device}/config/read         ?registers=a,b,c or ?all — streamed
GET    /devices/{device}/actions
POST   /devices/{device}/actions/{key}       restart the datalogger
PUT    /devices/{device}/meter-reading       supply a meter reading: {"watts": <signed>}
DELETE /devices/{device}/meter-reading       withdraw it
GET    /devices/{device}/accessories              everything enrolled, on either transport
POST   …/accessories/network/discovered/search    search: {"model":"shelly-pro-3em"} — streamed
POST   …/accessories/network/discovered           enrol one a search found: {"serial":"…"}
GET    …/accessories/network/discovered/{entry}   one record
DELETE …/accessories/network/discovered/{entry}   remove that one
POST   /devices/{device}/accessories/lora/pair    open a pairing window on the LoRa radio

A supplied meter reading expires after about two minutes and nothing here refreshes it, so a caller that wants one to persist writes it again inside that window. The device reports what it holds as meter_active_power, and meter_connected says whether it holds one at all.

A {key} is a field name or a register number. The config space is 146 registers, 0 to 145; the device volunteers 32 of them on connect and answers the rest only when asked.

Reading configuration in bulk

config/read answers application/jsonl — one JSON object per register as the device answers for it, then a final summary line. Name the registers with ?registers=, or the whole space with ?all; one or the other, not both. ?batch=N sets how many registers go in each request frame.

$ curl -N --unix-socket /run/heliobridge.sock -X POST \
    "http://local/devices/$SERIAL/config/read?registers=update_url,sdk_version,76"
{"register":76,"name":"wifi_signal","role":"dynamic","value":"-63"}
{"register":80,"name":"update_url","role":"dynamic","value":"http://cdn.growatt.com/update/…"}
{"register":61,"name":"sdk_version","role":"metadata","value":"IDFSDK:v4.4.3"}
{"requested":3,"answered":3,"silent":[]}

Answers arrive out of order and tens of seconds behind the request, which is why the response streams. Reading the whole space takes about 37 s at batch=1 and about 19 s at batch=8. Closing the connection stops the reading.

silent lists registers that answered nothing; some are simply unpopulated. role is identity for fields that carry the serial, the Wi-Fi passphrase or the Bluetooth handshake key.

Running in a container

ghcr.io/longsleep/heliobridge, for linux/amd64 and linux/arm64. Each release is tagged three ways: the full version, major.minor, and latest, which points at the highest release. Pin a full version in anything you deploy.

The device connects to port 7006, and the container needs one writable volume for the state directory.

podman run -d --name heliobridge \
  -p 7006:7006 \
  -v heliobridge-state:/state \
  -e HELIOBRIDGE_STATE_DIR=/state \
  -e HELIOBRIDGE_MQTT_URL=mqtt://broker.lan:1883 \
  -e HELIOBRIDGE_CONTROL_SOCKET=/state/control.sock \
  -e HELIOBRIDGE_RECORD_DIR= \
  -e HELIOBRIDGE_CLOUD_RELAY=false \
  -e HELIOBRIDGE_RELAY_MODE=controls \
  --read-only --cap-drop ALL --security-opt no-new-privileges \
  ghcr.io/longsleep/heliobridge:latest

docker run takes the same arguments. Under rootless Podman add --userns=keep-id if the volume is a host path rather than a named volume, so the uid inside matches the owner outside.

The volume must be writable by uid 65532, which the image runs as. A named volume inherits that on first use; a host path needs chown 65532:65532.

Compose

services:
  heliobridge:
    image: ghcr.io/longsleep/heliobridge:latest
    restart: unless-stopped
    ports:
      - "7006:7006"
    environment:
      # Holds the generated certificate, and is the only path written to.
      HELIOBRIDGE_STATE_DIR: /state
      HELIOBRIDGE_MQTT_URL: mqtt://broker.lan:1883
      # Placed in the state volume, which is already mounted; replaced on each start. Omit the
      # variable to run without the control API.
      HELIOBRIDGE_CONTROL_SOCKET: /state/control.sock
      # Empty turns raw frame recording off. It writes about 10 MB a day, so name a path under a
      # volume only while diagnosing something.
      HELIOBRIDGE_RECORD_DIR: ""
      # Whether to also connect to the vendor cloud.
      HELIOBRIDGE_CLOUD_RELAY: "false"
      # What the cloud may change while relaying: full, controls, or observer.
      HELIOBRIDGE_RELAY_MODE: controls
      HELIOBRIDGE_LOG: info
    volumes:
      - state:/state
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

volumes:
  state:

Works with docker compose and podman-compose.

Reaching the control API

The control API is a Unix socket, so it is not published as a port. In the examples above it sits in the state volume, and is reached from the host through that volume's path:

curl --unix-socket /var/lib/docker/volumes/heliobridge_state/_data/control.sock \
     http://localhost/devices

Or from another container that mounts the same volume. There is no shell to exec into.

Health

GET /healthz on the device-facing port answers ok when the server is serving, and is accepted only from loopback:

curl http://127.0.0.1:7006/healthz

heliobridge healthz asks the same question and exits 0 or 1, which is what the image's HEALTHCHECK runs. It reports whether the server is serving — not whether a device is connected, which is expected to be absent for hours at a time.

Relaying to the vendor cloud

Off by default in a container as everywhere else. HELIOBRIDGE_CLOUD_RELAY=true needs outbound TCP to mqtt.growatt.com:7006 and no further setup. To trust a private authority for your own broker, mount it and set SSL_CERT_FILE.

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.
  • One seam between the server and the manufacturer: framing, decoding, commands, the register catalogue, the relay policy, the cloud endpoint and firmware are each a trait the server owns and a driver implements. Only the binary names Growatt, and a test fails the build if anything else does.

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

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages