glenn schrooyenandClaude Opus 5 6c348974a3
checks / unit (pull_request) Successful in 6m14s
PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
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  ->  0a1e61d  2026-08-23  Packaged as a Home Assistant add-on
    -G  ->  06661c5  2026-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
2026-09-06 23:12:03 +02:00

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:

  1. ESP32 watchdog — no fresh setpoint for ~30 s → command 0 W, and keep commanding it.
  2. Wind-down before firmware updates — 0 W written before the update starts.
  3. 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.
S
Description
No description provided
Readme
18 MiB
Languages
Python 98.1%
JavaScript 1.2%
CSS 0.3%
Shell 0.2%
HTML 0.2%