The desk half of SPIKE-04. No control behaviour changes; this is documentation and the check that stops it drifting again. Three places stated inverter B's rating - config.yaml's commented roster example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would POST" - and all three said 3000 W, a number nobody had measured or read off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power 4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does not gate this site. All three copies now read 4600. ⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity is recorded rather than guessed. The datasheet prints it under BATTERY INPUT DATA, so it is probably DC, while max_w is graded against 0x0518 - the inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds it correctly under either, once dev_max is read off the unit, so `max_w: 4600` is what an operator should configure today. goodwe/NOTES.md §1.1b carries the full record with the date and the method on every figure. A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN. test_validate.py now pins all three documented rosters: findable, in agreement, and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which stopped matching cannot leave "they all agree" green on the two sites still read. It also grades the PROVENANCE beside the number (the datasheet, the 0x0518 it is graded against, and the 4324 W the DC reading would make it) WITHIN 2500 characters of the roster rather than file-wide. ⚠️ The first version of that provenance check searched whole files and was ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green no matter what happened next to B's rating. That is a check whose data cannot reach the failure it names, in the file that exists to complain about them. It is fixed, and the window is what fixes it - deleting the caveat beside the number now goes red even where the same token survives elsewhere in the file. The windowed version immediately found that the DOCS.md caveat did not name 0x0518 at all; DOCS.md now says it. Four mutations, bytecode purged and counted at 0 before and after each, all RED (two of them 0-byte deltas that change a printed answer): M1 probe's operator blob 4600 -> 3000 0 B RED (value + agreement) M2 DOCS.md's DC/AC caveat deleted -157 B RED (provenance) M3 config.yaml alone 4600 -> 4324 0 B RED (agreement) M4 probe entity renamed, regex finds nothing RED (the vacuity guard) Restored, green again. The probe itself was re-run against Supervisor 2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0. ⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE. test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py build a second inverter at 3000 W against A's 5000 W. The only property those checks consume is that B's number is unmistakably not A's, and the real pair is a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit bug and ordinary control arithmetic produce indistinguishable numbers. Collapsing them onto the real values would buy realism those checks do not use and cost them their separating power, which is exactly what test_peakguard.py's own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a hypothesis about B's rating; they call it illustrative, and not a placeholder either - the placeholder era ended on 2026-09-04. Also recorded in the changelog for the cards that own them: 94 % battery-to-AC => ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is the number SAFETY-31 argues its threshold from without having one; < 13 W night self-consumption per unit, which nothing accounts for; and the A/B asymmetry in opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's 12 h drain timeout was derived on A and should be re-checked against B. ⚠️ Honest survivors: - goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN. This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for this repository only, as .gitea/workflows/checks.yml already states - so the cross-repo check is not buildable here. - No check can see whether 4600 is the RIGHT number. Everything above pins a datasheet reading against itself. Only the bench half of SPIKE-04 can say whether the unit agrees, and the DC-vs-AC ambiguity is the live example. - The constant is still named B_RATING_HYPOTHESIS in four test files, which reads as a hypothesis about B's rating when it is now an illustrative fixture. It is left alone deliberately: renaming ~20 sites across test_peakguard.py and test_inverter.py would collide hard with the open SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment directly above each definition says what the value is and is not. Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0 before and after. Co-Authored-By: Claude Opus 5 <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.