⚠️ A DISARMED SAFETY-02 GUARD ON AN INSTALL WHOSE STATE FILE SAYS IT IS PROTECTED. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` - a hardcoded constant - while its sibling `migrate_maint_keys(store, unit, roster)` takes the roster and refuses a write no machine reads. On a store that ran pre-DEV-02 flat and declares `inverters: [garage, shed]`, the latch moved to `unit/inverter-a/sign_tripped`, `_verify_moves()` read it back under the name it had been HANDED, was satisfied, and stamped the marker. Not one inverter came up latched, and the next start skipped the migration too. That is the MAINT-05 defect verbatim, still live in the function whose own docstring describes it, and `_grade_store_migration()` did not catch it: its "is anything namespaced latched" test was satisfied BY THE MISDIRECTED VALUE ITSELF. ⚠️ THE REASON IT WAS LEFT WAS A HALF-TRUTH. "There is no roster yet - the `Inverter`s restore their latch in their constructors." The KEYS must be written before the `Inverter`s read them; the NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off the options before anything is constructed - which is exactly what it already existed to do for `MqttPublisher` - and returns `[UNIT_A]` on the flat shape, so today's upgrade path is byte-identical. TWO EDITS, AND THEY DO DIFFERENT JOBS. Measured per leg by reverting each one: SAME-STEP leg STAGED leg both edits (shipped) clean latch armed, litter reported guard reverted latch armed ⚠️ DISARMED call site reverted latch armed, litter latch armed, litter So the GUARD carries the safety and the CALL SITE carries the hygiene and the withheld marker. The table is in test_rehearse.py part 5 because a reader who assumed the roster-aware migration re-arms the latch would delete the guard as redundant in a tidy-up and take the STAGED column with it - and STAGED is what "upgrade now, declare the roster later" produces, which is the path an operator actually takes. Only the guard reaches an install already stamped by 0.9.0. ⚠️ AND "OFF THE ROSTER" ALONE WAS TOO BLUNT, WHICH test_inverter.py CAUGHT. The first draft treated every off-roster latch as litter. In DEV-02's restart case the store carries `unit/inverter-b/sign_tripped = True` from B's own trip while the roster being graded holds only A - and the blunt rule re-armed a HEALTHY A off the flat key B's trip had mirrored. DEV-02's defect rebuilt inside its own guard, which is what the paragraph I was overruling had been right to fear. Narrowed to `UNIT_A`, which is the only name the broken migration ever wrote - `roster_names()` falls back to a POSITIONAL `inverter-N`, so `inverter-a` is not a name this code hands to anyone else. A latch stranded under any other name is a real unit out of today's config and still counts as protection. Residual, stated in the code rather than left to be found: a site whose own first unit is NAMED `inverter-a`, latched, then taken out of the roster gets re-armed on `inverters[0]` - a healthy unit held at 0 W until an operator clears it. The conservative direction, against a site DRIVING on a file that says it is latched. ⭐ AND FIXING THE CODE EXPOSED A FALSE POSITIVE IN THE HARNESS. With the latch now landing under `garage`, `rehearse_roster.py` reported VANISHED STORE KEY for `unit/inverter-a/sign_tripped` three lines above the same value sitting under `unit/garage/sign_tripped`. A key that moved to a namespace a machine reads has not vanished - and an alarm that fires on the harmless case is one an operator learns to dismiss before the real one arrives, which is CONTROL-C's own argument. The test is not "the name appears somewhere": same bare key, same value, under a key in `machine_read`, the set built by driving a real start. A latch that genuinely went missing has no such key and is still a finding, which M3 (delete the flat rollback view) still measures red. Test parts replaced deliberately, as both of them asked to be: - test_rehearse.py part 5 asserted the defect "as REQUIREMENTS so that a fix turns them red". It now asserts the fix, with 5b and 5c as negative controls - one per edit - and 5b doubles as the leg-splitter's positive control, since reverting the guard is the build where the two legs DISAGREE. - test_maintenance.py section 13 was headed "RECORDED, NOT FIXED" and said the fixing card "should expect them to go red and should replace them". Replaced, plus two cases it could not have had: the already-stamped store, and the `inverter-b` negative control for the narrowing above. 28/28 files pass. Not run: nothing - this is host-side only, no rig, no hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds net grid exchange at zero by emulating the inverter's smart meter over RS485, and runs the monthly battery maintenance cycle the vendor box was doing.
repository.yaml add-on repository metadata
goodwe_controller/ the add-on
config.yaml manifest + options schema (the per-site config form)
app/ the controller
control.py the control law — pure functions, no I/O
maintenance.py the monthly cycle state machine
hass.py Supervisor/Core API access
store.py persistent state in /data
mqtt.py optional status entities
web.py ingress UI — routes only, status and configuration
config_page.py the configuration page's markup, style and script
configui.py its field model, built from the Supervisor's own
schema + translations — owns no key, label or rule
selfconfig.py reading and writing this add-on's own options,
with the validate-first / compare-and-swap gate
main.py orchestration, heartbeat, failsafe behaviour
test_control.py runnable checks — no framework needed
test_maintenance.py runnable checks — walks a full cycle in fake time
DOCS.md the add-on's documentation tab
MUTATION.md how to run a mutation so the result means something
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
Picking this up after a break? Read HANDOVER.md first — current state,
what is known broken, and what to do next.
Install
Settings → Add-ons → Add-on store → ⋮ → Repositories → add
https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
then install GoodWe RS485 Controller.
⚠️ The repository must be PUBLIC for this to work. Home Assistant clones it
anonymously; against a private repo the store shows nothing and gives no useful
error. If it must stay private, install locally instead: copy
goodwe_controller/ into the machine's /addons/ folder, then
ha store reload — it appears as a Local add-on.
⚠️ Bump version: in goodwe_controller/config.yaml for every change.
Supervisor keys the built image by that version, so without a bump it silently
reuses the old image and your fix appears not to work — on your bench and on
every client's machine.
Requires Home Assistant OS or Supervised. Add-ons cannot be installed on HA Container or Core.
Read this first
The inverter holds its last command forever. It has no meter-timeout of its own — a controller that dies mid-command leaves the battery running until a human intervenes. Measured on real hardware: 5 kW of discharge held for 113 seconds after a controller went silent.
Three layers exist because of that, and only the third covers the Home Assistant machine itself dying:
- ESP32 watchdog — no fresh setpoint for ~30 s → command 0 W, and keep commanding it.
- Wind-down before firmware updates — 0 W written before the update starts.
- RS485 e-stop (optional) — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in FIELD-GUIDE.md
§10 signed.
Development
The control law and the maintenance machine are pure Python with no Home Assistant imports, so they run anywhere:
cd goodwe_controller
python3 test_control.py
python3 test_maintenance.py
Both must pass before shipping any change. To prove a check bites - the way
almost every claim in this repository was established - mutate the module and
run tools/mutation_run.sh, which clears __pycache__ first. It is not
optional: a same-size edit reverted inside one second leaves CPython reusing the
old bytecode, and the run grades code that is not there. goodwe_controller/MUTATION.md
has the mechanism, the measurement, and what the flags do not do. They are not unit-test theatre —
each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in control.py say which.
Compatibility
Check the serial number, not the model name. Characters 6-8 of the serial are the platform tag:
| tag | platform | verdict |
|---|---|---|
ESU EMU ESA BPS BPU EMJ IJL |
105 — ES/EM/BP, AA55-era meter bus | supported |
SPB SPN |
745 — SBP G2 (GW…-SBP-20) |
no |
ESN ESC |
745 — ES G2 (GW…-ES-20) |
no |
ETU EHU BTU … |
205/745/753 — ET/EH/BT hybrids | no |
SDT DST MSU NSU … |
PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different meter
protocol. The tags come from the goodwe library's PLATFORM_105_MODELS, which
is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe ES / BP family inverters (AA55 / RS485 meter-bus generation)
- The protocol is reverse-engineered. There is no vendor contract, and a firmware change on GoodWe's side could break every installation at once. Say so when you sell it.