Claude Opus 5 21f22b393a
checks / unit (pull_request) Successful in 6m20s
UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +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
23 MiB
Languages
Python 98.2%
JavaScript 1.2%
CSS 0.3%
HTML 0.2%
Shell 0.1%