glenn schrooyenandClaude Opus 5 f6d7bfcb04
checks / unit (pull_request) Successful in 7m38s
SAFETY-44: give the moved-not-lost branch a control that reaches it, and make the move injective
⚠️ THE CONTROL I WROTE LAST ROUND WAS INERT, AND REVIEW MEASURED IT. M12 ran its
mutant over `state-legacy.json` and the findings diff against the UNMUTATED tree
was EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
pre-existing litter, and `"migrated  unit/shed/" not in out` was trivially true
because a pre-DEV-02 FLAT store has no shed latch to lose - after migration only
the FIRST roster entry is ever latched.

The decisive measurement is the one it could not make: reverting the
`vanished_unit not in after.units` narrowing - restoring exactly the rule that
hides a real latch loss - left 28/28 GREEN.

⚠️ SO IT WAS HABIT 4 WEARING THE CLOTHES OF ITS OWN FIX, AND WORSE THAN THE
"NO CONTROL" IT REPLACED, because it reads as checked. That is this commit's
own argument about the M3 mis-citation, turned on the thing I wrote to answer it.

⭐ THE FIXTURE CAME FIRST THIS TIME. `state-both-latched.json` is a store at
SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched by ONE site
event, so the reasons match too - the arrangement in which same-value
suppression is most dangerous, and the one no existing fixture could produce.
M12's mutant then makes ONE roster unit's latch key VANISH while its sibling's
survives at the same value:

    shipped tree, both-latched fixture      exit 0, no findings
    one unit's latch key deleted            exit 1, ADDS
                                            VANISHED STORE KEY unit/shed/sign_tripped
    narrowing reverted                      ⚠️ RED  (was 28/28 green)

And it asserts the PRECONDITION - the fixture is clean on the shipped tree -
plus that the mutation ADDS findings rather than coinciding with ones the
fixture already had. An empty diff there is exactly what made the last draft
inert, and nothing said so.

⭐ AND THE MOVE IS NOW INJECTIVE, which is the narrower hole review found still
open. `moved_to` is "any machine-read key with the same bare name and the same
value", and `DEFAULTS["sign_tripped"] is False` means `True` is the ONLY
non-default value a latch can hold - so N vanished keys could all claim the SAME
destination, by construction rather than by coincidence. Measured on a rename
carrying `inverter-a` -> `garage` correctly while DROPPING `inverter-b` -> `shed`:

    migrated  unit/inverter-a/sign_tripped = True -> unit/garage/sign_tripped
    migrated  unit/inverter-b/sign_tripped = True -> unit/garage/sign_tripped

Zero store-key findings, on a run where `shed` came up UNLATCHED. Two keys
cannot both have become one key. `diff()` claims each destination once.

⚠️ AND THE CHECK FOR IT IS ANCHORED TO A RUN THAT ACTUALLY PRODUCES MIGRATED
LINES. A dedicated mutant was written first and asserted over an EMPTY list -
vacuous, passing, indistinguishable from working, the same defect twice in one
sitting. These fixtures cannot produce two claimants (`migrate_unit_keys()`
moves FLAT keys only, so one flat latch yields one migration), so rather than
build a fiction to grade, injectivity is asserted over part 5's real migrations,
with a PRECONDITION that there are at least two of them. THE TWO-CLAIMANT
ARRANGEMENT IS A STATED RESIDUAL: bounded in `diff()` by construction, not
measured end-to-end.

Finding 4, and my correction to it was also wrong. The comment cited "line 315"
and "two hundred lines below"; the fix I wrote said "~54 lines down", which was
review's distance to the WRONG line, carried to a new referent without
re-measuring - inside the sentence apologising for a wrong number. Recomputed at
5e382f5: 327-261 = 66, 327-252 = 75, and 54 was neither. There is no distance
now, because a distance is a coordinate and the sentence already names the thing.

And the residual gains its second half, which review noticed and I had not: the
reason re-armed from is the FLAT mirror, carrying whatever tripped LAST, so this
path can print one inverter's evidence under another's name. The latch is right
and the explanation may not be. `test_maintenance.py`'s check that the reason is
not the removed unit's is guaranteed by that fixture's flat string, NOT by the
code, and now says so.

28/28 files pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 18:08:16 +02:00

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:

  1. ESP32 watchdog — no fresh setpoint for ~30 s → command 0 W, and keep commanding it.
  2. Wind-down before firmware updates — 0 W written before the update starts.
  3. 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.
S
Description
No description provided
Readme
25 MiB
Languages
Python 98.2%
JavaScript 1.2%
CSS 0.3%
HTML 0.2%
Shell 0.1%