No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.
`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.
The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.
WHAT REPRODUCED - and it is the part the compatibility promise rests on:
* the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
measured, not argued.
* the flapping family is 0.0 s everywhere in all 840 runs - the family that
defeated SAFETY-30's site window still cannot touch this check.
* 45.0 s at twice A's lag, to the cycle.
WHAT DID NOT:
* the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
two (20.0 -> 30.0 s).
* the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
at 100 W as a property of the reduction. This rebuild's curve is monotone.
The mechanism is real and is still the reason not to read either table as a
sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
written down as weaker.
* the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
there, but that gap is the honest measure of how much of the original table
was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
RANGE and no individual value; the rebuild prints its choices above its own
table so the caveat travels with the number.
* the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.
185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.
⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.
⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.
Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.
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.