config.yaml said 0.9.0 while the live box runs 0.7.0 with 0.7.0's files, so it has never received 0.8.0 or 0.9.0 either. 28 runtime commits had landed since the 0.9.0 bump at86bdf49with no version to carry them. MINOR, AND NOT BECAUSE OF THE NUMBER OF TICKETS. A config that is legal on 0.9.0 is REFUSED on 0.10.0: TARIFF-07 retires three price_provider ids and refuses them by name at startup (the ids stay in the schema enum precisely so validate.py's message is reachable rather than an opaque Supervisor rejection); TARIFF-18 moves price_taxes_eur_kwh 0.06 -> 0.11736, and the non-obvious half is the refusal that deliberately does NOT happen, because SUPERSEDED_TAXES_DEFAULTS_EUR_KWH keeps a taxes period table beside a stored 0.06 from becoming exit 78 on upgrade, with the rung measured un-weakened against a genuinely custom 0.1198; SAFETY-10 adds shadow_mode shipped `auto`, where an existing controlling install does not change by one watt and a fresh install comes up observing. AND A 0.7.0 INSTALL CROSSES 0.8.0 ON THE WAY. `ha addons update` does not walk versions, so 0.8.0's rule 10 lands at the same moment: an explicit slew_w above SLEW_RAIL_FRAC x rail is refused, and 0.7.0's OWN shipped default of 1000 is refused against a 555.6 W ceiling. Measured red on this tree, with slew_w 400 measured green - which is what the live box carries. Someone reading only the newest section would otherwise miss the one release that can stop the add-on starting. THE BOUNDARY WAS DRAWN HONESTLY RATHER THAN TIDILY. All 18 table rows in the top block merged after86bdf49- verified per row with git merge-base --is-ancestor - so the block is genuinely this run's work and was retitled as-is with its 18 `_this branch_` placeholders resolved to merge shas. But 0.10.0 ships MORE than that table lists: SAFETY-43 and TEST-21 are documented in a SECOND `## Unreleased` block sitting below 0.9.0, and SAFETY-44 - which changed app/main.py and app/store.py - is in CHANGELOG.md NOWHERE AT ALL. All three are named in the 0.10.0 preamble rather than moved, because re-filing is PKG-08. The second block is annotated, not rewritten or emptied: UX-02 in it shipped in 0.9.0 and is already in 0.9.0's own contents table, while the other three shipped here. Verified independently before merge: all 30 changelog deletions fall inside `## Unreleased` blocks and none in a released section. THE BLOCK'S OWN STATED REASON FOR THE VERSION CHOICE WAS WRONG AND THE SECTION SAYS SO. It said the number depended on whether 0.9.0's image had been built and pulled, and that if it had not, this work belonged inside 0.9.0. That was written before 0.9.0's section was cut. It has been cut since86bdf49with 28 runtime commits behind it, so reusing 0.9.0 would make one version string name two different trees - and the Supervisor keys the built image on exactly that string, which is the PKG-06/07 no-op in its worst form. Evidence: 29/29 green. predeploy_check.py --deployed-version 0.7.0 --options <live blob> clears at exit 0 both before and after, unchanged but for the shipped version line. No default was touched, so test_defaults_pin, test_price's mirror and test_version_gate stay green for the right reason. Eight mutations, no survivors, one at zero bytes. One was a SURVIVOR on the first draft - the slicer control was a sentinel only an EOF-running slicer could reach, so deleting a heading never got there; it asserts an invariant now, exactly one contents table per section. Card numbers that did not reproduce, corrected in the report: the block is 1541 lines citing 31 ids rather than 5487 citing ~70 (those figures fit the whole file and the Detail notes block, which is PKG-08's subject); 28 runtime commits rather than 30; 18 tickets rather than fifteen. FCST-04 has a table row but no section body - pre-existing, left alone.
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.