Files
goodwe-addon/goodwe_controller/DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
T
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

18 KiB

UX-04 — what the Configuration tab renders, and the pin that was never built

Status: DECIDED 2026-09-07. Three decisions and one confirmation, in the order UX-01's unmet acceptance criteria are numbered:

  1. UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as unsatisfiable, not deferred. The target was arithmetically unreachable, and the part of it that was reachable would be bought with the upgrade path. The measurement is now graded in tools/supervisor_shape_probe.py §F rather than printed, so the withdrawal cannot be quietly reversed.
  2. UX-01 AC 4's third clause is now BUILT: test_defaults_pin.py, an AST check, with its red direction executed on every run.
  3. UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on release/1.0, by UX-02's bcee235, not by UX-01's fef6ef2. The attribution is corrected here; no work was re-done.
  4. The 8 per-entry inverters: translations STAY. They do not reach the Supervisor tab and cannot be made to; UX-02's ingress panel is where they become visible.

Filed with UX-04. Card 6a9ec94617e9c48ed74ca5dd. Every number below was measured on release/1.0 at 6e7b233 on 2026-09-07, against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 — the version ha info reports on the live install — and against the 26-file check suite in goodwe-addon-checks:1. Nothing here contacted the live Supervisor at 192.168.2.158, posted options, or restarted anything.


1. Hiding: withdrawn, and why that is the safe answer

1.1 What AC 3 claimed

"The visible count drops to ~15, with the rest behind HA's show unused optional configuration options toggle."

1.2 What the tab actually renders

config.yaml declares 71 keys under options: and the same 71 under schema:. The set that HA's toggle is able to hide — schema-only keys — is therefore empty:

    hideable = set(schema) - set(options)  ->  []        (0 of 71)

On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden before UX-01, nothing is hidden after it, and no line of fef6ef2 moved a key either way. AC 3 is not partially met; it is unstarted.

1.3 The rule that decides it, replayed rather than paraphrased

From the frontend bundle shipped in this image, api/panel/frontend_latest/5680.*.js (frontend 20250925.1):

_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)

opts is the add-on's options as Supervisor serves them — the operator's stored blob after options: has been deep-merged into it. So a field is hidden only when it is both absent from options: and not required. The probe now runs that filter on this manifest rather than quoting it.

1.4 Reason one: the target was never reachable

Supervisor's own UiOptions marks required on 59 of the 71 fields; 12 are optional. The filter above shows a required field unconditionally. So the floor — what the tab renders with options: emptied altogether, which is the most hiding that is even expressible — is 59 fields.

options: block fields rendered
as shipped (71 keys) 71
emptied entirely (0 keys) 59
AC 3's target ~15

AC 3 asked for a number roughly four times below the floor. No arrangement of options: reaches it. The criterion could not have been met by any implementation; it was mis-specified, and the honest disposal of a mis-specified criterion is withdrawal, not a partial gesture toward it.

1.5 Reason two: the 12 hides that are expressible cost the deep-merge

Moving a key out of options: is not free. App.options is deepmerge(config.yaml defaults, the stored user options), so a key absent from options: has no default for the Supervisor to merge.

That is exactly the mechanism the 0.9.0 upgrade rests on. From CHANGELOG.md, the pre-deploy check run against this release on 2026-09-07:

The reference install's stored blob has 70 keys, not 71. delivery_hold_s arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing ?, which is the shape that becomes exit 78. It resolves to its options: default of 185 because the Supervisor deep-merges.

Hiding a key is done by taking it out of options: and marking it ?. That is, precisely, the edit that removes a key's merge default — the thing standing between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12 fewer rows on a form, on a site where the add-on is holding a real house's grid exchange at zero.

1.6 Reason three: it would promote 27 unpinned literals to load-bearing

And the cost compounds with §2 of this document. test_defaults_pin.py part 4 ledgers 27 (module, key) pairs in app/ that still answer a missing option with an in-code literal. Today those literals are unreachable on a Supervisor install, because the deep-merge guarantees every options: key is present in the served blob. Remove keys from options: to hide them and those literals stop being dead code: they become 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 the card's own AC 3.

1.7 What now grades this

tools/supervisor_shape_probe.py §F. The count was printed there from the day it was written — "the Configuration tab renders 71 fields" — and the probe exited 0 through UX-01's whole review while displaying the number that contradicted the criterion it was evidence for. A measurement that is displayed but not asserted is how this passed review. Three assertions replace the bare print:

PASS  every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
      - the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS  ...so replaying the frontend's own _filteredSchema on the served options
      hides NOTHING: 71 of 71 fields render
PASS  ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
      expressible - the tab still renders 59 required fields. THAT is the
      floor, and AC 3 asked for ~15.

Every number in them is computed from the manifest and from Supervisor's own required flag; none is typed. The red direction was run: deleting one key (price_feed_url) from options: and nothing else turns the first two to FAIL — Hideable: ['price_feed_url'], 70 of 71 fields render — and the probe exits 1. The counterfactual is isolated: one key varied, nothing else.

So if a future editor empties options: to shorten the form, this goes red and somebody has to read §1.5 before merging.


2. The defaults pin: test_defaults_pin.py

2.1 What AC 4 asked for and what shipped

AC 4 has three clauses.

clause state before UX-04
"every schema: key absent from options: has exactly one defaults-table entry" vacuous — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all
"the table changed → red" works, six times over, once per mirror in its own suite
"no module carries its own literal fallback for a schema default — an AST check, not a grep" never built

The only literal scan in the repository before this card is test_validate.py:1025: a re.findall restricted to eight maintenance_* keys across three named files. It is a grep, it predates UX-01, and it was not written for this.

2.2 What an "in-code literal fallback" is, structurally

A call in app/ taking a string literal that names a schema: key, with a constant in the next positional slot:

opts.get("max_w", 2000)            _number(opts, "max_w", 2000.0)

Found by walking ast.Call nodes — so a multi-line call and a renamed opts are found, and the same text inside a comment or docstring is not. Two exclusions, both structural and both argued at the site:

  • getattr(obj, "max_w", 0.0) reads an attribute off an object, not an option off a blob. Excluded by callee name.
  • the empty string. opts.get("static_profile", "") is Python's "not stated", and all six such sites in validate.py normalise it immediately — str(opts.get(k, "") or "").strip(). It cannot be a stale copy of a number, which is the whole of the CFG-04 defect class. Excluded by value. 0 is not excluded: slew_w: 0 and integrator_max_w: 0 are real shipped defaults with real meanings.

2.3 What it grades, measured on release/1.0 @ 6e7b233

2. the mirrors, DISCOVERED from the tree rather than listed here
    6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
                maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
                price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
    30 mirrored key(s) of 71 in `schema:`
3. the AST scan
    scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
    34 over unmirrored ones
4. the CFG-03 ledger
    27 (module, key) pair(s) carry a literal for a key no mirror answers for

Four properties, none of them vacuous:

  1. The mirrors are discovered, not listed. Six suites already pin their own table to config.yaml — and every one of those pins names its table by hand, so a seventh table added tomorrow would be pinned by nobody and nothing would say so. This walks app/ for module-level *_OPTION_DEFAULTS assignments and grades whatever it finds against config.yaml. That is the property the six hand-written pins cannot have.
  2. No literal survives for a key a mirror already answers for. 30 keys, 0 violations.
  3. The CFG-03 ledger is FROZEN, by (module, key) pair, not by count. A count goes green again the moment somebody deletes one site and adds another. Line numbers are deliberately not in the key: a ledger that goes red on an unrelated reformat is a ledger people learn to re-bless without reading. The 27 pairs are the open CFG-03 backlog; they are not removed here, because that is behaviour change across main.py, p1.py, inverter.py and validate.py that no single card should make in passing. The set can now only shrink.
  4. Every remaining literal is graded against config.yaml — the half that catches CFG-04's actual failure mode, two copies of one number and one moves.

2.4 One divergence found, and it is new

      main.py  target_grid_w: code 0 vs config.yaml -10

main.py:290 reads target_grid_w=float(opts.get("target_grid_w", 0)) while config.yaml ships -10. A dict handed to Controller without the key holds net exchange at 0 W instead of the shipped -10 W bias into import. It cannot reach an operator: on a Supervisor install the deep-merge guarantees the key is present, and the only paths that hand over a partial dict are sim/inject.py and this repository's own fixtures. It is recorded in the check as the single permitted divergence, with the 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.

2.5 ⭐ The red direction, shown rather than claimed

A check that has never been shown failing is not a check — which is exactly what clause one of this AC was. Two things follow from that:

(a) The red direction runs on every invocation. Part 5 feeds the scanner a synthetic module containing a deliberate violation of each rule and requires it to be flagged, in-process, through the same literal_fallbacks() the graded parts call. A scanner that goes blind — a renamed helper, an ast change, a SCHEMA that comes back empty — stops working here too, and the total site count is additionally graded against a floor.

(b) A literal was planted in the real tree and the check went red. Planted at main.py:290, float(opts.get("maintenance_charge_w", 2000)) — a mirrored key:

3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
    scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
  ok   the scanner is not blind: it found 35 sites (floor 20)
      main.py:290  get('maintenance_charge_w', 2000)
  FAIL no literal fallback survives for any of the 30 keys a mirror already
       answers for - those sites must index the mirror

1 FAILED     (exit 1)

And part 4's two directions, planted the same way with opts.get("p1_host", "x") and opts.get("slew_w", 999):

4. the CFG-03 ledger
    28 (module, key) pair(s) carry a literal for a key no mirror answers for
  FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
      main.py  p1_host: code 'x' vs config.yaml ''
      main.py  slew_w: code 999 vs config.yaml 0
      main.py  target_grid_w: code 0 vs config.yaml -10
  FAIL every remaining literal still AGREES with config.yaml, except the 1
       divergence(s) recorded above it

     (exit 1)

The second of those is the CFG-04 defect class itself: a literal that has drifted away from config.yaml while the file still says something else. Both plants were removed; the tree is byte-identical to 6e7b233 apart from this card's own files.


3. CFG-04: closed on release/1.0, by UX-02, not by UX-01

UX-01's AC 5 claimed the double-literal max_w hole. It did not close it. fef6ef2 touched config.yaml, translations/, test_translations.py and the probe — no defaults code at all — and on the 0.7.0 tree the triple mutation survives: 21 files, 0 RED.

Confirmed on current release/1.0 @ 6e7b233, 2026-09-07. The same triple mutation, 2000 -> 2500 at the three descendants of 0.7.0's inverter.py:438 / validate.py:383 / validate.py:684:

0.7.0 release/1.0 @ 6e7b233
inverter.py:438 max_w=float(opts.get("max_w", 2000)) inverter.py:640 raw = opts.get("max_w", 2000)
validate.py:383 cap = _number(opts, "max_w", 2000.0) validate.py:466, same line
validate.py:684 max_w = _number(opts, "max_w", 2000.0) validate.py:836, same line

Result — 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, so the box
cannot be emptied into a quietly halved rail

git log -L 310,325:goodwe_controller/test_selfconfig.py attributes that check to bcee235, "UX-02: pin both halves of the max_w fix, because neither was graded", glenn schrooyen, 2026-09-06 — not to UX-01. The hole ships closed with the pending 0.9.0 deploy. The AC is met by UX-02. No work was re-done here; the mutation was run once, on a throwaway copy, to confirm it.


4. The 8 per-entry translations: they stay

4.1 The measurement, and it is negative

translations/{en,nl}.yaml carry a name and a description for all 8 per-entry keys of the repeatable inverters: section. They are loaded by Supervisor's own loader, they pass SCHEMA_APP_TRANSLATIONS, and test_translations.py grades their coverage. They do not reach the screen, and this is not a defect in them. UiOptions gives inverters type: "schema" with multiple: True, and the frontend's _convertSchemaElement sends exactly that case to a selector rather than to a nested ha-form:

if ("schema" === t.type && !t.multiple) return {...type:"expandable"...}   // not us
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
    o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }

and ha-selector-object labels those fields fields[name].label || name, while _convertSchemaElementToSelector sets neither label nor translation_key. So an operator sees raw name, setpoint_entity, max_w, bms_soc_floor. The probe already asserts this, negatively and by name.

4.2 Decision: keep them. Four reasons.

  1. The Supervisor tab cannot be made to render them. The gap is in somebody else's converter, not in this repository. There is no change here that fixes it; deleting the translations does not fix it either.
  2. UX-02's ingress panel is where they become visible, and it renders the roster itself rather than handing it to ha-selector-object. The fields: block is the shape Supervisor's own schema defines for exactly this, and it is what a surface that wires label through consumes.
  3. They cost nothing. No runtime path reads them; they are two YAML blocks in files the Supervisor already parses, and the parse is graded green.
  4. Deleting them would remove the only thing that catches a per-entry key added without a translation. test_translations.py grades top-level and per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's bms_soc_floor — a required per-entry key added after UX-01 — would have joined the roster with no label and nothing to say so.

This is a written decision, not a change. The tree is unmodified in translations/.

4.3 Not re-filed

tools/supervisor_selfapi_probe.py has been known red since OPT-09 — it hardcodes len(_sub) == 7 against 8 per-entry keys. That is TOOL-04, already filed, and it is not re-filed or fixed here. It is named only so a reader of §4.1 knows why the other probe is the one quoted.


5. What this card did not do

  • It did not remove a key from options:. See §1.5 — the deep-merge default is what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
  • It did not pay down the 27-pair CFG-03 ledger. See §2.3.
  • It did not correct main.py's target_grid_w fallback. See §2.4.
  • It did not touch translations/. See §4.2.
  • It did not re-do CFG-04. See §3.
  • It did not touch hardware, the live Home Assistant at 192.168.2.158, or sim/. Both probe runs were throwaway containers over a read-only mount.