Review findings, all confirmed in code before changing anything. THE RULE DID NOT AUTHORISE ITS OWN BEST REFUSAL. The stated test was "makes a safety mechanism inoperative, or makes a stated limit untrue", and a blank setpoint_entity does neither - layer 1 pinning the inverter at 0 W is the failsafe WORKING, as validate.py's own text admitted. The real justification is a second, separate category: the add-on is inert and indistinguishable from healthy. Stated as (a) and (b), with each refusal tagged, and with both halves explicitly bounded - (a) is not "any two numbers that could disagree", (b) is not "anything degraded". Left implicit, the gap is where trivia-creep comes back in. meter_max_age_s ABOVE THE FIRMWARE'S VETO IS NOW REFUSED. The schema allows int(5,300); the firmware's threshold is a compile-time `max_age_s: "30"` and it trips layer 1 at `p1_age_s >= 30`. At 120 the add-on believes a 119 s reading is fresh and keeps commanding while the board has already vetoed and is holding at 0 W - healthy meter, running controller, nothing moving. Structurally identical to the max_w case, and unlike max_w it is settable on this install, so the headline behaviour finally has a live demonstration. That firmware file's own comment says a lower bound on this "belongs in CFG-01, not here". Only ABOVE is refused: below the veto the add-on gives up first, which is fail-safe and visible on its own status page. Scoped to P1 being enabled, because meter_max_age_s governs nothing otherwise. FIRMWARE_INGEST_VETO_S cannot be imported from a compile-time YAML substitution, so it carries a note on the single direction in which it can go stale - and a warning that TEL-06 is editing `ingest_stale`, so a matching integer may stop meaning a matching rule. stop= WAS A LIVE FOOTGUN. It defaulted to None, which made a Controller that cannot stop itself the DEFAULT one. On the late-refusal path that meant: log the banner, return, and leave the process alive forever with no control loop and no exit - the exact "inert but looks healthy" failure this module exists to refuse. Now required and keyword-only, so the broken shape does not construct; test_p1.py's five call sites pass an Event. That whole path had no test and no live demo. It has one now: it drives run_control() against a device that only reports a narrower range once the loop starts, and asserts it refuses, signals, returns, and writes NOTHING - plus the converse, that an agreeing device leads to real writes. This also covers the max_w composition that cannot be reached through the Supervisor on the live install. max_w <= 0 is refused. Schema-blocked in production, but validate_startup_config() is the simulation suite's entry point too, and a non-positive max_w inverts the arbiter's supervised bounds into a contradiction that reads as a safety trip rather than a typo. Verified live 2026-08-28 (backup goodwe_controller-cfg01r2-1787940076): meter_max_age_s 120 refused with a message naming 120, 30, and THE FIRMWARE WINS; no traceback; add-on restored byte-exact and running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
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
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
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. 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.