With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage 3's closed rig against478ba93, metrics recomputed rather than taken from the card: `peak_cap_w` 1500 / 1200 W house -> 28 charge onsets an hour, mean period 131.1 s, the grid swinging -455.5 to +3200.0 W; at the SHIPPED `peak_cap_w: 3500` with an 1800 W house -> 8/h, with a 3000 W house -> 32/h. The bite rule is `load + max_w > peak_cap_w`, i.e. any house over 1500 W in the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for hardware wear and - per §9.1 - for the peak it is trying to avoid." And it was losing the argument it was having: EVERY billed quarter in the three affected configurations finished OVER the cap. ⚠️ THE CARD'S STATED MECHANISM IS WRONG AND THE CORRECTION IS ON THE RECORD. The card, and .cache/s45.md before it, say the loop is the quarter boundary resetting the projection and arbitrage re-arming at full power. It is not. Of the 27 gaps between the 28 onsets, 23 are 90-120 s (mean 111.5 s) and lie ENTIRELY INSIDE one quarter; the four long gaps - 230, 240, 250, 255 s - are exactly the four that SPAN a boundary, so the boundary LENGTHENS the interval rather than causing an onset. The real cycle, off the recorded fields: the trade charges at max_w while `wanted = batt_w + excess` is negative and the guard files NOTHING; `wanted` crosses zero; the guard engages on a floor of 5.3 W; that flips `arbitrage.guard_state()`, which cancels the WHOLE trade; the grid-following loop - which targets grid = 0, not grid = ceiling - takes the actuator and discharges to +1870 W, exporting 420 W; the projection collapses; the guard releases; the trade re-arms at max_w. A binary interlock keyed on a continuous quantity. A fix aimed at the boundary would have removed four of twenty-eight onsets. THE FIX IS A SHAPE THIS ADD-ON ALREADY HAD. `control.maintenance_charge_floor()` caps a MAINTENANCE charge at "the headroom left under the site's cap", because "a maintenance charge is the only thing this system does that is big enough and long enough to set that peak". ⚠️ That sentence stopped being true when OPT-04 landed. `peakguard.charge_headroom_w()` publishes the same quantity off the MEASURED guard rather than off the optional `peak_forecast_entity` - the hole SAFETY-25 had to close in `shaping_suspended()` - and `arbitrage.decide()` sizes its BUY leg to it, keyword-only and with no default. NO NEW ARITHMETIC AND NO NEW CONSTANT. The number is `wanted_w` read in the other direction: `decide()` already computes the DISCHARGE that lands the quarter on the ceiling, and a negative one is a permitted CHARGE of `-wanted`. ⚠️ `peakguard.decide()` IS UNCHANGED - sha256(decide) = 200090d0835a3a55, 7252 B on this branch and on478ba93- so 2026-08-20's anti-bang-bang rung is exactly where it was, and tools/replay_ac3.py reproduces 190/190 guarded quarters, 110 engaged cycles, 5 blind and 878 Wh on both trees. TWO PROPERTIES MAKE IT SAFE TO HAND A STRATEGY, BOTH SWEPT RATHER THAN ARGUED. It is INVARIANT IN THE BATTERY - raise the charge by dW, the meter rises by dW, so `excess` rises by dW while `batt_w` falls by dW and `wanted` does not move: 1350.0 W at every point across batt_w -5000..+2000 W on one banked quarter, while the `excess` those runs see takes six distinct values. It is feed-forward, so consuming it adds no feedback path around the plant. And HOLDING IMPLIES ZERO OR UNKNOWN: across 1080 swept combinations not one ENGAGED decision answers a positive headroom. AFTER, same rig, same command. 1500/1200: 28 onsets -> 1, grid swing 3655.5 -> 282.0 W, energy bought 180.0 -> 285.2 Wh (+58%, because a continuous 300 W trade beats a 2000 W trade cancelled for 57% of the hour), billed quarters 1490/1497/1509 -> 1500.0/1500.0/1500.0. 3500/1800: 8 -> 1, 3511/3536/3559 -> 3500/3500/3500. 3500/3000: 32 -> 1, 3519/3542/3557 -> 3500/3500/3500. The two shipped-default rows buy 2.2% and 1.6% LESS energy and in exchange the worst quarter goes from 59 W over the cap to zero - which is the ordering §10.1 asks for, since the capacity tariff bills the month's single worst quarter. ⚠️ AND THE NEGATIVE CONTROL: at peak_cap_w 5000 with a 1200 W house, where `load + max_w` never reaches the cap, the two runs are BIT-IDENTICAL. ⚠️ A NEGATIVE `lo` WAS CONSIDERED AND REJECTED, AND THE REASON IS IN THE CODE. Deleting `decide()`'s `wanted <= 0` gate and filing `lo = wanted` is the tidier answer arithmetically. It creates a new `lo > hi` CONTRADICTION against main.py's cheap-window maintenance shaping, which files a NEGATIVE `hi`; arbiter.py clause 2 has no notion of one limit out-ranking another, so it resolves to 0 W - the battery PARKED in the quarter that sets the bill - and the only way to close that suspends the maintenance charge shaping PERMANENTLY on every site this card is about. A backoff or cooldown in arbitrage was rejected too: both modules' design notes say no timer is invented, and the trade now re-arms at `ceiling - load` rather than at max_w, which is what learning from a refusal means when the refusal is a measurement. THE REGRESSION IS THE LOOP SETTLING, NOT A UNIT TEST ON decide(). test_arbitrage.py 3c closes the loop over the shipped guard, strategy, arbiter, control law and QuarterAverager with a modelled plant, and asserts BOTH directions: unsized it must run away (24 onsets, -414..+3200 W, a quarter over the cap) and sized it must settle (1 onset, 303 W swing, three quarters at exactly 1500.0 W). The two runs differ in one argument. A third pair asserts the card is INERT where the defect is inert. Evidence: 29/29 green. 14 mutations, every one RED on a named check, no survivors; two are ZERO BYTES - `min()` -> `max()` on the sizing, and the sized-trade sentence's two operands transposed - and both are red. M13 reverts the 2026-08-20 rung and is red on EIGHT named checks in test_peakguard and three in test_arbitrage. Every revert verified by a tree fingerprint AFTER the revert, which is how a defect in the battery's own harness was found: two edits to one file, restored in list order, left the first one on disk (MUTATION.md habit 18). test_peakguard.py part 1 also gained a None-safe format, because the mutation it exists to catch made it raise TypeError one line later and ABORT the file - parts 2-13 were ungraded and M13's recorded coverage was one check instead of eight. Not fixed, and named: TEST-23 finding 1's export overshoot at the END of a trade (356-938 W, ~0.37 x max_w) is a different card; a house whose load ALONE exceeds the cap still has the trade refused outright and the loop drive to grid = 0 rather than to the ceiling, and no swept pair reaches that regime; the container leg (hadrive.py) was not re-run because test23s3-addon is deliberately stopped as a second writer on the setpoint entity. 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 — 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:
- 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.