Two ways a deploy of this add-on has gone wrong, and the Supervisor reports both as success. THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so `ha apps update` against an unchanged version installs nothing and says it worked. PKG-06 recorded this on 2026-09-05 with six merges behind an unbumped version. PKG-07 recorded it again on 2026-09-06 with six. Both times a human noticed. Nothing else was going to. THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the add-on serving it is not running, so recovery means the Supervisor UI. ⚠️ THE FIRST VERSION OF THIS TOOL WAS BROKEN ON EVERY REAL REPOSITORY, AND ITS OWN TESTS COULD NOT SEE IT. The "when did the version last change" lookup was `git log -S 'version: "'`. `-S` is the pickaxe: it finds commits that change the COUNT of a literal string. A bump edits the line IN PLACE - `version: "0.7.0"` -> `version: "0.8.0"` - so the substring still occurs exactly once before and after, and `-S` never sees it. It returned only the commit that first INTRODUCED the line. MEASURED on admin/goodwe-addon, in its current correctly-bumped state: -S ->0a1e61d2026-08-23 Packaged as a Home Assistant add-on -G ->06661c52026-09-06 PKG-07: bump 0.7.0 -> 0.8.0 so the shipped script reported a correctly bumped tree as 87 COMMITS BEHIND AN UNBUMPED VERSION, listing PKG-06's and PKG-07's own bump commits among the offenders. A tool whose entire purpose is to be trusted before a deploy cried wolf on every run. ⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'` matches any commit whose diff touches a line of that shape - a reformat, a quote change, a comment moved onto the line. So `-G` narrows the candidates and then the VALUE is compared against the parent's. The question is "did the version change", and only comparing the value answers it. ⚠️ WHY THE TESTS MISSED IT, WHICH IS THE FINDING BEHIND THE FINDING. Every fixture built a fresh repository whose `version:` line is introduced once and never edited - the one shape in which `-S` looks correct, because introducing the line does change the count from 0 to 1. A test that builds its own one-bump history is a test asserting against a structure it built itself: MUTATION.md habit 4, in the file written to prevent a recurrence, and the thirteenth recorded instance. There is now a fixture with THREE version states and runtime commits between them, and it goes red on four checks against the old `-S` lookup. ⚠️ WHY THIS IS NOT A CI CHECK. "A pull request that touches runtime code must bump the version" would put every open branch in conflict over one line of config.yaml and still not prevent the failure, which happens at DEPLOY and not at merge. A cheaper form review proposed - a NON-REQUIRED check on push to release/1.0 that raises a visible alarm when commits accumulate behind an unbumped version - is a good idea and is filed as PKG-10 rather than built here. WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the Supervisor API and a supervisor token. Without --options the validator half SKIPS - loudly, exit 2 - and never pretends. `app/validate.py` imports its siblings and those need aiohttp, so on a bare host that half skips too and says which container to re-run it in. A missing or malformed --options file is now a REFUSAL with a sentence rather than a traceback. EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS. Also corrected: this file said PKG-06 was "four merges" when its own commit message says six, and the test docstring said habit 4 had eleven instances when MUTATION.md already said TWELFTH. Both were caught in review. `requirements.txt` stays in RUNTIME although the Dockerfile installs via apk - over-inclusive is the safe direction and costs only an unnecessary bump. Suite: 25 files, all pass (25 with UX-02's test_selfconfig.py now in the base). 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.