adminandClaude Opus 5 8a6acd9fa2
checks / unit (pull_request) Successful in 8m0s
SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +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
docs/RELEASE.md            how a change reaches the battery - `master` is
                           protected, releases go via PR from `release/1.0`

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
20 MiB
Languages
Python 98.2%
JavaScript 1.2%
CSS 0.3%
HTML 0.2%
Shell 0.1%