Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.
WHAT THE SCALARS WERE SILENTLY ASSERTING
dev_min/dev_max one pair, read from one setpoint_entity. A second
inverter's rating was NEVER READ AT ALL, so its clamp, its
freeze rail and the peak guard's achievable_w were the
first one's numbers.
soc one reading into the maintenance FSM, SAFETY-24's pack
floor and arbitrage's bounds. An average lets the emptier
pack be driven below its own floor.
sign_wd ONE SignWatchdog, whose own docstring says "one instance
per actuator", latched under FLAT store keys - so a trip
caused by B latched A across restarts and one operator
press cleared both.
last_beat one deadline and one serialised hass.call(), so a hung
write to A ate B's 30 s layer-1 budget.
actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:
gain one unit's power the site sum
0.2 510 W 0 W
0.4 250 W 0 W
0.6 50 W 0 W
That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.
THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.
HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.
live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.
AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.
EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.
INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.
Co-Authored-By: Claude Opus 5 (1M context) <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
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
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.