admin 587495e143 Merge pull request 'SAFETY-47: say what a shadow run can and cannot show' (#90) from SAFETY-47 into release/1.0
DOCS.md, app/shadow.py's header and DECISIONS/SAFETY-10 all claimed the strategies ask for a fixed power from inputs that do not depend on what the battery did, so a shadow run shows you the real trades. That was true when shadow mode was designed and false by the time it merged: SAFETY-45 landed hours earlier and made the buy leg min(asked, headroom), where headroom = (900/remaining) x (ceiling - projected) and `projected` reads the open quarter's accumulator - the meter, which is exactly a function of what the battery did. The strategy became closed-loop and the exemption went stale.

Measured at a binding cap, 1500/1200/2000, the same hour twice: live grid 1224-1506 W held at the ceiling against a flat 1200 W in shadow; SoC 50.000019 -> 51.856789 % against 50.000000 -> 50.000000; the two runs differ on 716 of 720 cycles; the final recorded figure is -302.4 W live against -2000.0 W in shadow, 6.61x. On a SAFETY-45-reverted tree the limit cycle is 28 onsets live against 1, guard engaged 410/720 against 0/720. Two of the three defects the card cites as motivation are closed-loop phenomena shadow cannot exhibit; TARIFF-14's half genuinely is visible, hour and price and spread identical 720/720.

THE CAVEAT'S DIRECTION WAS MEASURED RATHER THAN WRITTEN, and the ticket had it wrong. A draft saying a live run reaches a SMALLER number was checked pairwise over 3600 charging cycles in five configurations: the shadow record is never below live - zero cycles below and 730 EXACTLY EQUAL, at the start of each quarter where the amplification is 1.0. `Smaller` would have been false on 730 rows. The shipped sentence says UPPER BOUND and the two-point arithmetic behind it is graded.

Caveated rather than re-computed, with the rejections recorded: re-computing would make the recorded decision no longer the decision the shipped path reached while arbiter, clamp, the SAFETY-28 gate, the log and MQTT all ran on the real one - a second controller everywhere except the wire; recording nothing blanks the row exactly when arbitrage trades, and the log's edge key IS the watts; recording the unsized ask beside it adds a second number with no warning, and at the shipped 3500/2000 both read 2000 W. The trap avoided: keying the caveat on `capped` would fire at the quarter's START, where the record is right, and go SILENT at the end, where it is 6.61x out. It keys on the headroom having been MEASURED.

AND THE TICKET'S OWN ANALYSIS WAS INCOMPLETE. I said the committed evidence table could not fail for two reasons. They are independent in the FIX direction too: widening the fixture to a binding cap still leaves the comparison passing, because it reads index zero and the first charging cycle is -300.0000 W in both runs. Fixing only the fixture closes nothing. Recorded in MUTATION.md as a new habit-4 shape - an INDEX, not a fixture - and the agreement at index zero is now graded as an expectation so nobody rebuilds on it.

Evidence: 29/29 green. Fourteen mutations, no survivors. One was a REAL survivor first - deleting the caveat from the action log left all 29 files green, because the sentence was an f-string in _shadow_settle() and the only suite that drives it runs with meter_source: off; it is shadow.log_line() now. One CORRECTLY survived and is reported as such: min -> max makes the record larger, which is still an upper bound, so it is not the inversion that matters. And the new section nearly shipped FCST-06's shape - a mutation made it raise on its first min() and report zero reds - so every check now carries the section's precondition.

Two design limits stated rather than smoothed: the caveat discloses but does not help, since an engineer still cannot obtain the real number without arming the battery - the fix for that is a rig mode replaying the site's own captured meter through the shipped decision path, filed separately and deliberately NOT by giving shadow mode a plant model, which is the one change that would break its safety argument. And SAFETY-10's takeaway still oversells `auto`, which this card did not rewrite.
2026-09-15 01:36:41 +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
18 MiB
Languages
Python 98.1%
JavaScript 1.2%
CSS 0.3%
Shell 0.2%
HTML 0.2%