Review found two things this PR asserted about itself that were false. Both are mine and both are worse than the defect the card was filed for. ⚠️ "NO FIXTURE CAN RESTORE IT, MEASURED BOTH WAYS" WAS NOT TRUE, AND TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF. I deleted M12 - SAFETY-44's working end-to-end control for the off-roster narrowing - on the argument that the provenance clause made it unrestorable. The structural claim behind it, that `run()` builds both snapshots from one state file so "already latched" and "made by this transition" are incompatible, IS TRUE OF SAME-STEP AND FALSE OF STAGED: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there. Both arrangements I measured were same-step shaped. Review found the third. RESTORED, and it discriminates with all six clauses in: staged leg, legacy+shed, shipped _m12_new = VANISHED unit/shed/sign_tripped ...C5 narrowing removed _m12_new = [] ⚠️ suppressed ⚠️ AND MY FIRST RESTORATION DIDN'T DISCRIMINATE EITHER, for a reason worth recording: `data.pop(k, False)` RETURNS the value, so shed lost the KEY and kept the LATCH - `_grade_store_migration()` saw `on_roster_latched`, never re-armed garage, and there was no transition-made destination. The finding was reported whether or not C5 existed and the control looked restored. `and False` is the difference between "the key is gone" and "the LATCH is gone", and only the second reaches the re-arm this arrangement needs. ⚠️ WHAT ACTUALLY BROKE WAS M12's exit-0 PRECONDITION, NOT M12. A legacy-shaped fixture is never clean. That precondition is the one SAFETY-44's own commit called "belt-and-braces, load-bearing only if the exit-0 requirement is ever relaxed", saying what closed the gap is `_m12_new` BEING A SET DIFFERENCE - and a stable dirty baseline is fine for a set difference. So I deleted a control on the strength of a precondition the same commit had already called not load-bearing. That is removing coverage rather than failing to add it, which is the worse of the two directions. ⭐ AND THE BARE-KEY CLAUSE HAD NO ROW AT ALL. Review deleted `k.rsplit("/", 1)[-1] == bare` and the whole 28-file suite stayed GREEN. 5e mutated five clauses; the comprehension carries more, and this one is load-bearing: a vanished SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42 `delivery_tripped` on a roster unit - a DIFFERENT FAILSAFE's key. The rule's own comment says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare key", and nothing measured that sentence. `unit/*/delivery_tripped` is machine-read, so it is reachable on any real two-latch store. R7 added; C2 now flips exactly R7. All six clause-removals now flip exactly their own row: C1 machine_read -> ['R6-machine-read'] C5 off-roster -> ['R2-narrowing'] C2 same bare key -> ['R7-bare-key'] C6 injectivity -> ['R5-injectivity'] C3 same value -> ['R4-value'] C7 provenance -> ['R3-provenance'] THREE DANGLING CITATIONS TO A CONTROL THIS PR HAD DELETED, one of them in the shipped tool eight lines below the paragraph condemning exactly that ("a citation is the worst possible carrier, because it reads as having been checked"). The tool no longer names a check; it states the property - every clause has a row, and each removal flips its own. A STALE SAFETY-44 BLOCK saying both fixes were "REVERTED OUT OF THIS CARD, because THIS SUITE CANNOT MEASURE EITHER ONE" - describing a tree this PR replaced. Rewritten as a map of where each clause is now measured. A comment that outlives its subject is the same defect as a citation that outlives its check. AND A SELF-CONTRADICTION about the driver, in the file and in the last commit message: "THE DRIVER IS WRITTEN INTO THE TREE UNDER TEST" eight lines above "THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE". The code does the tempdir; the first sentence stated the read-only-CI bug the same commit says it fixed, as if it were the design. It now says the driver IMPORTS the tree and is WRITTEN to a tempdir. ONE HOLE REMAINS OPEN AND IS STATED AS UNMEASURED: attribution. `sorted()` decides which vanished key claims a destination, so a deleted key can sort first and be named as the one that migrated while the key that really moved is reported VANISHED. Review measured that `sorted(` -> `list(` leaves the suite green - every 5e case has at most one candidate, so no row can see ordering. Still exit 1, so a wrong sentence rather than a hidden loss. Recorded rather than left looking guarded. 28/28 files pass. 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.