CFG-04: an operator can declare a second inverter, and the shape was measured against the Supervisor #45

Merged
admin merged 2 commits from CFG-04 into release/1.0 2026-09-04 11:51:58 +02:00
Owner

Fix round — 2026-09-04, after review

The review had one blocking finding, and it was not in the design or the
shipped code: tools/supervisor_shape_probe.py §B did not measure what four
documents said it measured, and it was regression-blind for the property it
was cited for.

What was wrong. §B validated the reconstructed live options blob, which
is missing many required keys that have nothing to do with this card. Its
refusal therefore named whichever of those the validator reached first — the
committed transcript recorded meter_freeze_s, the reviewer's run printed
p1_phase_export_entities, and a run today printed sign_disagree_w. The
quoted Missing option 'inverters' in root was never produced by the
instrument.
Worse, §B asserted a bare not ok, so deleting inverters: []
from config.yaml left it printing PASS — blind to the exact regression it
is the committed evidence for.

The underlying claim is true, and is now measured properly.

What changed.

  • tools/supervisor_shape_probe.py §B is now the isolated counterfactual:
    the full shipped defaults with the key (expect accept), and the same
    defaults without it and nothing else varying (expect refusal). It asserts
    on the key name in the refusal string, so the line goes red if the
    refusal is about anything else. The probe also exits non-zero when any
    case fails — without that, a transcript is only evidence to someone who reads
    it.
  • The old live-blob case survives as §B2, relabelled for what it actually
    is: an incomplete reconstruction whose raw form is refused for unrelated
    missing keys, kept because its merged half genuinely demonstrates
    deepmerge(options:, stored).
  • DECISIONS/CFG-04-inverter-roster.md — §4's "§B shows both halves" sentence
    and the line-103 transcript corrected; the whole transcript re-run and
    replaced with a real one
    ; §3 gains an explicit row for the default.
  • CHANGELOG.md, test_inverter.py's part-9 comment and its part-12
    mutation row
    re-attributed to §B, with the row now recording that the probe
    itself was blind to that mutation until review.
  • MUTATION.md gains this as the sixth instance of "the check's data
    cannot reach the failure it names" — the first one found outside the suite,
    in a committed evidence artifact — plus three habits: assert on the content
    of a refusal, vary one thing from an accepted baseline, and treat a
    committed transcript as a check subject to the mutation battery.

The proof the reviewer asked for — inverters: [] deleted from
config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:

== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
  FAIL  the shipped `options:` block validates AS IT STANDS - ...
        Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
...
== 2 CASE(S) FAILED ==

exit 1. Key restored, same image, same command: == all cases passed ==,
exit 0. Full output in the PR comment below.

Also fixed, both raised non-blocking

  • validate.py rules 5 and 6 named the WRONG INVERTER. unit_caps()
    skips an entry with no usable max_w, so a caps index stopped being a
    roster index the moment one entry was malformed — and both rules fed a caps
    index to _unit_label(), which indexes the roster. New
    unit_caps_indexed() carries the roster index with the cap. Latent (rule
    9(d) refuses such a config in the same pass) and fixed anyway, with a
    regression check in part 10 that runs the old expressions on the same
    fixture and shows them producing the wrong names.
  • DOCS.md's downgrade note said "safe". True of the schema, incomplete
    about the add-on: rule 9(e) requires the flat setpoint_entity be cleared
    when a roster is declared, so a downgrade lands on a blank actuator and the
    older version refuses to start until someone refills the field. Confirmed by
    running release/1.0's own validate_startup_config() on that blob. The note
    now says recoverable and loud, not safe, and tells the operator to write the
    entity down first.

19/19 green.


The defect

DEV-02 made every per-device value per-instance — rating, SoC, watchdogs,
heartbeat, store keys — so the code stopped assuming one inverter. The
options schema did not. build_roster() returned a list of exactly one and
said so in its own docstring, and there was no inverters: key for an operator
to write. The roster could only ever be a roster of one.

The shape, and how it was established

config.yaml had no list-of-objects anywhere, and static_profile carries a
whole tariff structure as a JSON string in a str? precisely because a nested
schema was doubted. Whether the Supervisor's options validator accepts one on
this HA version was unverified, and guessing wrong is not a compile error — it
is an operator whose add-on will not start after an update, with the schema
pinned to the version they cannot install.

So it was run, not reasoned. tools/supervisor_shape_probe.py (committed)
executes Supervisor 2026.08.0's own AppOptions validator and its own
SCHEMA_APP_CONFIG
, out of ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,
against this repository's config.yaml. 2026.08.0 is what the live install
reports. Nothing is re-implemented; if the Supervisor changes its rules the
script changes its answer. It touches nothing — it reads the file and runs a
validator in a throwaway container, and never talks to the live Supervisor.

Full transcript and the argument: DECISIONS/CFG-04-inverter-roster.md.

what was tested what it did
the whole manifest, list-of-objects and all accepted by SCHEMA_APP_CONFIG
a two-inverter options blob accepted, per-entry types enforced
an entry with max_w: 9999 refused — "value must be at most 5000.0"
an entry missing name / setpoint_entity / max_w refused by name — "Missing option 'name' in inverters"
a JSON string where the list belongs refused — "Invalid list for option 'inverters'"
the Supervisor UI renderer {"type": "schema", "multiple": true} — a repeatable section an operator can edit
the ROLLBACK direction (new options, old schema) key dropped with a warning, not refused

⚠️ The measurement that decided the default

§B is an isolated counterfactual: exactly one key differs between its two
runs.
The shipped options: block validates as it stands; the same block
with only inverters removed
is answered with
Missing option 'inverters' in root — a nested list whose element is a dict
is never optional to _check_missing_options(). Every existing install's stored
options are that blob, because the key did not exist when they were written.
The probe asserts on the key name in the refusal, not on "a refusal
happened", so it goes red if the refusal is ever about some other key.

(§B2 is a separate, honestly labelled case: a reconstructed pre-0.6.0 blob,
merged with the defaults, validating. Its RAW half is refused for whichever
unrelated required key the incomplete reconstruction lacks first — not
evidence about inverters. See the fix round at the top of this description.)
App.options is the shipped options: block
deep-merged with the operator's stored one, so inverters: [] in
config.yaml is the difference between an upgrade nobody notices and an add-on
that cannot start. That is docs/deploy-rollback.md §4's schema-conflict trap in
the forward direction, found before it was sprung.

The merge strategy was checked too, because it is the other way this goes wrong:
_OPTIONS_MERGER is type_strategies=[(dict, ["merge"])] with an override
fallback, so lists override rather than concatenate — a non-empty default
could not have grown a roster with inverter A declared twice.

What was chosen, and why not the JSON-string precedent

str? + JSON works and is what would have shipped had the list failed. It loses
on three things:

  1. SAFETY-28's writer census stops working. actuator_writers() walks the
    options tree for setpoint_entity at any depth — its own docstring
    anticipates this exact card. Inside a JSON string those entities are
    invisible, so the §1.5 two-writers guarantee would have needed
    re-implementing against a parsed copy: a second place for the census to be
    wrong.
  2. No per-field validation — one opaque string, and every error message
    written here instead of by the Supervisor.
  3. No UI — a text box for hand-typed JSON, versus a repeatable section.

Nothing is inherited by an entry that omits a key, which is DEV-02 restated
as a config rule. name, setpoint_entity, max_w required; a missing
soc_entity means this unit has no SoC sensor, never use the other one's.
Defaults still live in exactly one place (CFG-03) — there is no per-entry
default anywhere, because there is no optional per-entry number.

Two permissive readers in shipped code, and three in the checks

  • validate.py rules 5 and 6 compared their thresholds against the flat
    max_w. At a roster of two 2000 W units with max_w: 5000 left over from a
    single-inverter install, sign_disagree_w: 3000 passed rule 5 while
    SAFETY-02's watchdog could fire on neither unit — rule 5's own failure mode,
    reached through rule 5. They read unit_caps() now; at one inverter that is
    [max_w] exactly.
  • main.py's run_control() re-ran the device-rating check behind
    if self.o.get("setpoint_entity") — the flat key, which a roster leaves
    blank — so on a two-inverter site no inverter's max_w was ever graded
    against the device it is written to, silently.
  • test_peakguard.read_option() / _schema_bound() and
    test_inverter.read_schema_max()
    matched a stripped line and took the
    first hit, so the nested per-inverter max_w: began outranking the flat one —
    silently and correctly, because the two bounds are the same
    int(100,5000) today. A reader that is right by coincidence is this project's
    named failure mode. test_tariff.py's copy of the idiom went with them.

test_inverter.py part 11 finds every config.yaml-reading function in the
suite — transitively, because two of the readers this card changed never name
the file — executes each against a synthetic two-inverter config.yaml
whose per-inverter numbers differ from its flat ones, and fails if any answers
with the per-inverter value. The table is not maintained by hand: the signature
decides. config.yaml declares inverters: before max_w: on purpose and
part 11 asserts that ordering, so the trap stays baited.

Verified by breaking it

Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each — including reverting
each hardened reader, inheriting the flat soc_entity/max_w, dropping rule
9(e), rule 5 back to the flat max_w, run_control()'s check back inside
if entity:, removing the inverters: [] default, and allowing duplicate names.

Two survived the first run and the CHECKS were changed, not the table, both
recorded in MUTATION.md:

  • the inheritance check had blank flat keys, so "inherited" and "not
    inherited" were the same empty string;
  • the run_control() check asserted a source line was present — and
    re-indenting it does not remove the line. It drives the shipped
    run_control() now, under a deadline, because that mutation's real
    symptom is a hang rather than a red.

Acceptance criteria

AC where
1. Supervisor handling verified on this HA version tools/supervisor_shape_probe.py §A–§E, DECISIONS/CFG-04-inverter-roster.md §2–4; the inverters measurement is §B, re-run 2026-09-04 against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0
2. two-inverter config → two-unit roster reaching live_units() test_inverter.py part 9
3. one-inverter config loads unchanged, suite unmodified part 9 ([] and key-absent are byte-identical); 19/19 green
4. every config reader handles it or fails loudly part 11, fed a two-inverter config.yaml
5. actuator_writers() refuses two inverters on one setpoint_entity part 10
6. verified by breaking it part 12, ten mutations, all red
7. defaults in exactly one place no per-entry default exists; test_maintenance/test_price/test_watchdog CFG-03 pins still green

19/19 green in the goodwe-addon-ci image. Version bumped to 0.6.0 — the
Supervisor keys the built image by version:.

What I am unsure about, and what a reviewer should look hardest at

  • AC 3's "the exact options blob the live install runs today" is a
    RECONSTRUCTION, not the real blob.
    The HA API rejected the token in
    ~/.goodwe-env (HTTP 401) and the only backup containing
    local_goodwe_controller is protected: true, so I could not read
    /data/options.json. The blob in the probe is assembled from HANDOVER.md
    §1 (gain 0.6, max_w 5000, slew 1000, deadband 15, step 10, saturation 500/3,
    heartbeat 10 s, maintenance disabled, e-stop fitted) and
    docs/deploy-rollback.md §0/§B.5 (meter_source: homewizard_local,
    p1_host, meter_poll_s). The property that actually matters is structural
    and holds for any blob
    : no options written before this version contain
    inverters, and the merge with the shipped [] is what makes them validate.
    But if someone can ssh root@192.168.2.158 'cat /data/.../options.json', it is
    worth re-running the probe against the real thing.
  • validate.py rule 6 grades maintenance_charge_w against the first entry of unit_caps_indexed(),
    because Controller.maint_unit is inverters[0]. That is a coupling between
    two files. I chose it over min(caps) deliberately — min() would refuse a
    5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches
    — but it is the line that has to move when a second Maintenance FSM arrives,
    and it is only a comment that says so.
  • The boot-time gate in amain() still probes only the flat
    setpoint_entity,
    so at a roster config dev_min/dev_max are None there
    and the per-unit range check happens in run_control() instead. That matches
    the existing design (ESPHome is usually not up at boot, so the gate tolerates
    unknown ranges and run_control() re-checks), but it does mean a two-inverter
    site's first range verdict arrives a moment later than a one-inverter site's.
  • test_peakguard.py failed 7 checks once, on a heavily loaded host, and has
    not reproduced since
    (three clean runs, plus the full suite twice). The
    failures clustered on the SAFETY-24 floor-limited scenario, which calls
    PeakTracker.project(..., datetime.now(timezone.utc)) — a wall-clock read
    whose position within the billed quarter changes the projection. I believe it
    is pre-existing wall-clock sensitivity rather than anything this branch does
    (none of the changed readers alters the values it consumes — verified by
    printing old-reader and new-reader answers side by side, identical for every
    key), but I did not chase it and it is worth a second opinion.
  • Two checks in test_validate.py were edited, and I want that seen rather
    than skimmed: SAFETY-28 wrote them against a guessed CFG-04 shape (id
    keys, entries with only a setpoint_entity). One is updated to the shape this
    card actually chose; the other asserted that a blank per-inverter entity left
    the whole config accepted, and this card decided the opposite — a roster entry
    nothing can command is refused. The census claim it was making is preserved
    and now asserted directly on actuator_writers(). The hazard fixture is
    deliberately left in the old guessed shape, because its claim is that the
    census finds writers structurally under a container it was never told about.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

> ## Fix round — 2026-09-04, after review > > The review had **one blocking finding**, and it was not in the design or the > shipped code: **`tools/supervisor_shape_probe.py` §B did not measure what four > documents said it measured, and it was regression-blind for the property it > was cited for.** > > **What was wrong.** §B validated the *reconstructed live options blob*, which > is missing many required keys that have nothing to do with this card. Its > refusal therefore named whichever of *those* the validator reached first — the > committed transcript recorded `meter_freeze_s`, the reviewer's run printed > `p1_phase_export_entities`, and a run today printed `sign_disagree_w`. **The > quoted `Missing option 'inverters' in root` was never produced by the > instrument.** Worse, §B asserted a bare `not ok`, so deleting `inverters: []` > from `config.yaml` left it printing **PASS** — blind to the exact regression it > is the committed evidence for. > > **The underlying claim is true**, and is now measured properly. > > **What changed.** > * `tools/supervisor_shape_probe.py` **§B** is now the isolated counterfactual: > the full shipped defaults **with** the key (expect accept), and the same > defaults **without it and nothing else varying** (expect refusal). It asserts > on the **key name in the refusal string**, so the line goes red if the > refusal is about anything else. The probe also **exits non-zero** when any > case fails — without that, a transcript is only evidence to someone who reads > it. > * The old live-blob case survives as **§B2**, relabelled for what it actually > is: an incomplete reconstruction whose raw form is refused for **unrelated** > missing keys, kept because its *merged* half genuinely demonstrates > `deepmerge(options:, stored)`. > * `DECISIONS/CFG-04-inverter-roster.md` — §4's "§B shows both halves" sentence > and the line-103 transcript corrected; **the whole transcript re-run and > replaced with a real one**; §3 gains an explicit row for the default. > * `CHANGELOG.md`, `test_inverter.py`'s part-9 comment and its **part-12 > mutation row** re-attributed to §B, with the row now recording that the probe > itself was blind to that mutation until review. > * `MUTATION.md` gains this as the **sixth instance** of "the check's data > cannot reach the failure it names" — the first one found outside the suite, > in a committed evidence artifact — plus three habits: assert on the *content* > of a refusal, vary one thing from an *accepted* baseline, and treat a > committed transcript as a check subject to the mutation battery. > > **The proof the reviewer asked for** — `inverters: []` deleted from > `config.yaml`, probe re-run against > `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`: > > ``` > == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key == > FAIL the shipped `options:` block validates AS IT STANDS - ... > Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) > ... > == 2 CASE(S) FAILED == > ``` > exit **1**. Key restored, same image, same command: `== all cases passed ==`, > exit **0**. Full output in the PR comment below. > > ### Also fixed, both raised non-blocking > * **`validate.py` rules 5 and 6 named the WRONG INVERTER.** `unit_caps()` > *skips* an entry with no usable `max_w`, so a caps index stopped being a > roster index the moment one entry was malformed — and both rules fed a caps > index to `_unit_label()`, which indexes the roster. New > `unit_caps_indexed()` carries the roster index with the cap. Latent (rule > 9(d) refuses such a config in the same pass) and fixed anyway, with a > regression check in part 10 that runs the **old expressions** on the same > fixture and shows them producing the wrong names. > * **`DOCS.md`'s downgrade note said "safe".** True of the *schema*, incomplete > about the *add-on*: rule 9(e) requires the flat `setpoint_entity` be cleared > when a roster is declared, so a downgrade lands on a blank actuator and the > older version refuses to start until someone refills the field. Confirmed by > running `release/1.0`'s own `validate_startup_config()` on that blob. The note > now says recoverable and loud, not safe, and tells the operator to write the > entity down first. > > 19/19 green. --- ## The defect DEV-02 made every per-device value per-instance — rating, SoC, watchdogs, heartbeat, store keys — so the **code** stopped assuming one inverter. The **options schema** did not. `build_roster()` returned a list of exactly one and said so in its own docstring, and there was no `inverters:` key for an operator to write. The roster could only ever be a roster of one. ## The shape, and how it was established `config.yaml` had no list-of-objects anywhere, and `static_profile` carries a whole tariff structure as a JSON string in a `str?` *precisely because* a nested schema was doubted. Whether the Supervisor's options validator accepts one on this HA version was unverified, and guessing wrong is not a compile error — it is an operator whose add-on will not start after an update, with the schema pinned to the version they cannot install. So it was **run**, not reasoned. `tools/supervisor_shape_probe.py` (committed) executes **Supervisor 2026.08.0's own `AppOptions` validator and its own `SCHEMA_APP_CONFIG`**, out of `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`, against **this repository's `config.yaml`**. 2026.08.0 is what the live install reports. Nothing is re-implemented; if the Supervisor changes its rules the script changes its answer. It touches nothing — it reads the file and runs a validator in a throwaway container, and never talks to the live Supervisor. Full transcript and the argument: `DECISIONS/CFG-04-inverter-roster.md`. | what was tested | what it did | |---|---| | the whole manifest, list-of-objects and all | **accepted** by `SCHEMA_APP_CONFIG` | | a two-inverter options blob | **accepted**, per-entry types enforced | | an entry with `max_w: 9999` | refused — *"value must be at most 5000.0"* | | an entry missing `name` / `setpoint_entity` / `max_w` | refused **by name** — *"Missing option 'name' in inverters"* | | a JSON string where the list belongs | refused — *"Invalid list for option 'inverters'"* | | the Supervisor UI renderer | `{"type": "schema", "multiple": true}` — a repeatable section an operator can edit | | the ROLLBACK direction (new options, old schema) | key **dropped with a warning**, not refused | ### ⚠️ The measurement that decided the default **§B is an isolated counterfactual: exactly one key differs between its two runs.** The shipped `options:` block validates as it stands; the **same block with only `inverters` removed** is answered with **`Missing option 'inverters' in root`** — a nested list whose element is a dict is never optional to `_check_missing_options()`. Every existing install's stored options are that blob, because the key did not exist when they were written. The probe asserts on the **key name in the refusal**, not on "a refusal happened", so it goes red if the refusal is ever about some other key. *(§B2 is a separate, honestly labelled case: a reconstructed pre-0.6.0 blob, merged with the defaults, validating. Its RAW half is refused for whichever unrelated required key the incomplete reconstruction lacks first — **not** evidence about `inverters`. See the fix round at the top of this description.)* `App.options` is the shipped `options:` block **deep-merged** with the operator's stored one, so `inverters: []` in `config.yaml` is the difference between an upgrade nobody notices and an add-on that cannot start. That is `docs/deploy-rollback.md` §4's schema-conflict trap in the **forward** direction, found before it was sprung. The merge strategy was checked too, because it is the other way this goes wrong: `_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an `override` fallback, so **lists override rather than concatenate** — a non-empty default could not have grown a roster with inverter A declared twice. ## What was chosen, and why not the JSON-string precedent `str?` + JSON works and is what would have shipped had the list failed. It loses on three things: 1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the options *tree* for `setpoint_entity` at any depth — its own docstring anticipates this exact card. Inside a JSON *string* those entities are invisible, so the §1.5 two-writers guarantee would have needed re-implementing against a parsed copy: a second place for the census to be wrong. 2. **No per-field validation** — one opaque string, and every error message written here instead of by the Supervisor. 3. **No UI** — a text box for hand-typed JSON, versus a repeatable section. **Nothing is inherited by an entry that omits a key**, which is DEV-02 restated as a config rule. `name`, `setpoint_entity`, `max_w` required; a missing `soc_entity` means *this unit has no SoC sensor*, never *use the other one's*. Defaults still live in exactly one place (CFG-03) — there is no per-entry default anywhere, because there is no optional per-entry number. ## Two permissive readers in shipped code, and three in the checks * **`validate.py` rules 5 and 6** compared their thresholds against the **flat** `max_w`. At a roster of two 2000 W units with `max_w: 5000` left over from a single-inverter install, `sign_disagree_w: 3000` **passed rule 5** while SAFETY-02's watchdog could fire on *neither* unit — rule 5's own failure mode, reached through rule 5. They read `unit_caps()` now; at one inverter that is `[max_w]` exactly. * **`main.py`'s `run_control()`** re-ran the device-rating check behind `if self.o.get("setpoint_entity")` — the *flat* key, which a roster leaves blank — so on a two-inverter site **no** inverter's `max_w` was ever graded against the device it is written to, silently. * **`test_peakguard.read_option()` / `_schema_bound()` and `test_inverter.read_schema_max()`** matched a **stripped** line and took the first hit, so the nested per-inverter `max_w:` began outranking the flat one — **silently and correctly**, because the two bounds are the same `int(100,5000)` today. A reader that is right by coincidence is this project's named failure mode. `test_tariff.py`'s copy of the idiom went with them. `test_inverter.py` **part 11** finds every `config.yaml`-reading function in the suite — *transitively*, because two of the readers this card changed never name the file — **executes** each against a synthetic two-inverter `config.yaml` whose per-inverter numbers differ from its flat ones, and fails if any answers with the per-inverter value. The table is not maintained by hand: the signature decides. `config.yaml` declares `inverters:` **before** `max_w:` on purpose and part 11 asserts that ordering, so the trap stays baited. ## Verified by breaking it Ten mutations run through `tools/mutation_run.sh` and observed **red**, listed in `test_inverter.py` part 12 with the check that bites each — including reverting each hardened reader, inheriting the flat `soc_entity`/`max_w`, dropping rule 9(e), rule 5 back to the flat `max_w`, `run_control()`'s check back inside `if entity:`, removing the `inverters: []` default, and allowing duplicate names. **Two survived the first run and the CHECKS were changed, not the table**, both recorded in `MUTATION.md`: * the inheritance check had **blank flat keys**, so "inherited" and "not inherited" were the same empty string; * the `run_control()` check asserted a **source line** was present — and re-indenting it does not remove the line. It drives the shipped `run_control()` now, **under a deadline**, because that mutation's real symptom is a hang rather than a red. ## Acceptance criteria | AC | where | |---|---| | 1. Supervisor handling verified on this HA version | `tools/supervisor_shape_probe.py` §A–§E, `DECISIONS/CFG-04-inverter-roster.md` §2–4; the `inverters` measurement is §B, re-run 2026-09-04 against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` | | 2. two-inverter config → two-unit roster reaching `live_units()` | `test_inverter.py` part 9 | | 3. one-inverter config loads unchanged, suite unmodified | part 9 (`[]` and key-absent are byte-identical); 19/19 green | | 4. every config reader handles it or fails loudly | part 11, fed a two-inverter `config.yaml` | | 5. `actuator_writers()` refuses two inverters on one `setpoint_entity` | part 10 | | 6. verified by breaking it | part 12, ten mutations, all red | | 7. defaults in exactly one place | no per-entry default exists; `test_maintenance`/`test_price`/`test_watchdog` CFG-03 pins still green | 19/19 green in the `goodwe-addon-ci` image. Version bumped to **0.6.0** — the Supervisor keys the built image by `version:`. ## What I am unsure about, and what a reviewer should look hardest at * **AC 3's "the exact options blob the live install runs today" is a RECONSTRUCTION, not the real blob.** The HA API rejected the token in `~/.goodwe-env` (HTTP 401) and the only backup containing `local_goodwe_controller` is `protected: true`, so I could not read `/data/options.json`. The blob in the probe is assembled from `HANDOVER.md` §1 (gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s, maintenance disabled, e-stop fitted) and `docs/deploy-rollback.md` §0/§B.5 (`meter_source: homewizard_local`, `p1_host`, `meter_poll_s`). **The property that actually matters is structural and holds for any blob**: no options written before this version contain `inverters`, and the merge with the shipped `[]` is what makes them validate. But if someone can `ssh root@192.168.2.158 'cat /data/.../options.json'`, it is worth re-running the probe against the real thing. * **`validate.py` rule 6 grades `maintenance_charge_w` against the first entry of `unit_caps_indexed()`,** because `Controller.maint_unit` is `inverters[0]`. That is a coupling between two files. I chose it over `min(caps)` deliberately — `min()` would refuse a 5000 W pack being recalibrated beside a 2000 W sibling the cycle never touches — but it is the line that has to move when a second `Maintenance` FSM arrives, and it is only a comment that says so. * **The boot-time gate in `amain()` still probes only the flat `setpoint_entity`,** so at a roster config `dev_min`/`dev_max` are `None` there and the per-unit range check happens in `run_control()` instead. That matches the existing design (ESPHome is usually not up at boot, so the gate tolerates unknown ranges and `run_control()` re-checks), but it does mean a two-inverter site's first range verdict arrives a moment later than a one-inverter site's. * **`test_peakguard.py` failed 7 checks once, on a heavily loaded host, and has not reproduced since** (three clean runs, plus the full suite twice). The failures clustered on the SAFETY-24 floor-limited scenario, which calls `PeakTracker.project(..., datetime.now(timezone.utc))` — a wall-clock read whose position within the billed quarter changes the projection. I believe it is pre-existing wall-clock sensitivity rather than anything this branch does (none of the changed readers alters the values it consumes — verified by printing old-reader and new-reader answers side by side, identical for every key), but I did not chase it and it is worth a second opinion. * **Two checks in `test_validate.py` were edited**, and I want that seen rather than skimmed: SAFETY-28 wrote them against a *guessed* CFG-04 shape (`id` keys, entries with only a `setpoint_entity`). One is updated to the shape this card actually chose; the other asserted that a blank per-inverter entity left the whole config accepted, and this card decided the opposite — a roster entry nothing can command is refused. The census claim it was making is preserved and now asserted directly on `actuator_writers()`. The **hazard** fixture is deliberately left in the old guessed shape, because its claim is that the census finds writers structurally under a container it was never told about. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-04 04:30:49 +02:00
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs,
heartbeat, store keys - so the code stopped assuming one inverter. The options
schema did not. `build_roster()` returned a list of exactly one and said so in
its own docstring, and there was no `inverters:` key for an operator to write,
so the roster could only ever be a roster of one.

THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN

config.yaml had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` precisely because a nested
schema was doubted. That is a question about somebody else's software, so it was
answered by running it: `tools/supervisor_shape_probe.py` (committed) runs
Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out
of the release image, against this repository's config.yaml. 2026.08.0 is the
version the live install reports. The full transcript and the argument are in
DECISIONS/CFG-04-inverter-roster.md.

It accepts the list, enforces per-entry types, refuses a missing `name` /
`setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a
repeatable section, and drops the key with a warning under an older schema - so
a downgrade is not the forward trap in reverse.

⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT
THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for
an options blob that has no such key - which every existing install's has, since
the key did not exist when they were written. `App.options` is the shipped
`options:` block deep-merged with the operator's stored one, so the empty
default is the difference between an upgrade nobody notices and an add-on that
cannot start with a schema pinned to the version that would fix it. That is
docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was
sprung. The merger was checked too: lists override rather than concatenate, so a
non-empty default could not have grown a roster behind the operator's back.

NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE

`name`, `setpoint_entity` and `max_w` are required of every entry. An entry that
omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A
three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a
card removing, in YAML. Defaults still live in exactly one place (CFG-03):
there is no per-entry default anywhere, because there is no optional per-entry
number. A flat `setpoint_entity` left set beside a declared roster is refused
rather than ignored - it names an actuator nothing will write.

TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS

  validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`.
  At a roster of two 2000 W units with `max_w: 5000` left over from a
  single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
  SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode,
  reached through rule 5. They read `unit_caps()` now, which at one inverter is
  `[max_w]` exactly.

  main.py's `run_control()` re-ran the device-rating check behind
  `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank
  - so on a two-inverter site no inverter's `max_w` was graded against the
  device it is written to, and the skip was silent. `validate_unit_ranges()`
  grades each unit against its own ESPHome and runs unconditionally.

  test_peakguard.py's `read_option()` and `_schema_bound()` and
  test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the
  first hit, so the nested per-inverter `max_w:` began outranking the flat one -
  silently, and correctly, because the two bounds are the same int(100,5000)
  today. A reader that is right by coincidence is this project's named failure
  mode. All three are anchored at exactly two spaces now; the file's two halves
  disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises
  rather than picking one. test_tariff.py's copy of the idiom went with them.

THE AUDIT IS A CHECK, NOT A PROMISE

test_inverter.py part 11 finds every config.yaml-reading function in the suite -
transitively, because two of the readers CFG-04 had to change never name the
file - EXECUTES each against a synthetic two-inverter config.yaml whose
per-inverter numbers differ from its flat ones, and fails if any answers with
the per-inverter value. The table is not maintained by hand: the signature
decides, so the eleventh reader is named on its first run. config.yaml declares
`inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so
the trap stays baited.

SAFETY-28's `actuator_writers()` needed no change - it was already a structural,
case-folding census - but until now there was no configuration in which two
writers on one actuator could be WRITTEN DOWN. There is now, and it is refused.

VERIFIED BY BREAKING IT

Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each. Two survived the first
run and the CHECKS were changed, not the table: the inheritance check had blank
flat keys, so "inherited" and "not inherited" were the same empty string; and
the `run_control()` check asserted a SOURCE LINE was present, which re-indenting
it does not remove. Both are recorded in MUTATION.md's "the check's data cannot
reach the failure it names" section, which now carries two more habits: assert
on behaviour rather than on source text, and give a check whose failure mode is
a hang a deadline.

19/19 green in the goodwe-addon-ci image. The one-inverter path is byte-
identical: the whole existing suite runs unmodified against it.

Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so
a schema change without a bump is a schema nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-04 10:33:58 +02:00
CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
22c016fadd
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.

THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED

tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.

And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.

THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY

Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.

The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.

VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT

`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:

  == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
    FAIL  the shipped `options:` block validates AS IT STANDS - ...
          Missing option 'inverters' in root in GoodWe RS485 Controller
  ...
  == 2 CASE(S) FAILED ==

exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.

THE FOUR MIS-CITATIONS

DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.

MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.

TWO NON-BLOCKING FINDINGS, ALSO FIXED

  validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
  entry with no usable `max_w`, so caps position i stops being roster position i
  the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
  `_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
  hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
  operator sent to the wrong device by the message that exists to stop that. New
  `unit_caps_indexed()` carries the roster index with the cap. It is latent -
  rule 9(d) refuses such a config in the same pass - but that was a property of
  today's rule ordering, not of the code, and validate_startup_config() is also
  sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
  really does desynchronise the two lists, then that both messages name the right
  unit, then the counterfactual: the OLD expressions, on that same config, still
  evaluate to the wrong names.

  DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
  the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
  flat `setpoint_entity` be cleared when a roster is declared, so a config that
  downgrades cleanly is by construction one whose flat actuator is blank, and the
  older version has nothing to write to. Confirmed by running release/1.0's own
  validate_startup_config() on that blob: one refusal, naming setpoint_entity.
  Recoverable and loud, but an outage until a field is refilled. The note says so
  now, and tells the operator to write the entity down before downgrading.

19/19 green in the goodwe-addon-ci image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Fix round pushed — 22c016f

The blocking finding is addressed at the instrument, not at the documents that
quoted it.

What was actually wrong

tools/supervisor_shape_probe.py §B validated the reconstructed live options
blob
, which is missing many required keys unrelated to this card. Its refusal
named whichever of those the validator reached first — the committed
transcript recorded meter_freeze_s, your run printed
p1_phase_export_entities, and a run today printed sign_disagree_w. The
quoted Missing option 'inverters' in root was never produced by the
instrument
. And with a bare not ok assertion, deleting inverters: [] left
§B printing PASS.

What §B is now

An isolated counterfactual: the full shipped options: block (expect
accept), then the same block with inverters removed and nothing else
varying
(expect refusal), asserted on the key name in the refusal string.
The old case survives as §B2, relabelled as an incomplete reconstruction
refused for unrelated keys. The probe now also exits non-zero on any failed
case.


The proof you asked for

Both runs against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0,
same command, same mounts. The only difference is the one line in config.yaml.

1. inverters: [] DELETED from config.yaml → the probe FAILS

App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
  PASS  Supervisor accepts the add-on manifest, version 0.6.0
  schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
  options ship    inverters -> null

== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
  FAIL  the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
        Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
  PASS  ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
        Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)

== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
  PASS  the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
        Missing option 'price_provider' in root in GoodWe RS485 Controller (local_goodwe_controller)
  FAIL  the same blob MERGED with these defaults - what App.options actually is
        Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== 2 CASE(S) FAILED ==
  - the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
  - the same blob MERGED with these defaults - what App.options actually is

exit 1.

_no_inv and the shipped defaults are now the same dict, so the accept-half has
nothing left to be a baseline for and goes red — which is exactly the signal
that ought to appear when the default is gone. §B2's merged half goes red with
it, for the same reason.

2. Key restored → the probe PASSES

App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
  PASS  Supervisor accepts the add-on manifest, version 0.6.0
  schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
  options ship    inverters -> []

== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
  PASS  the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
  PASS  ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
        Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)

== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
  PASS  the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
        Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller)
  PASS  the same blob MERGED with these defaults - what App.options actually is
        inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000

== C. AC2: a two-inverter blob an operator would POST ==
  PASS  two inverters
        [{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s

== D. what the schema itself refuses, per entry ==
  PASS  an entry with no name is refused
        Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
  PASS  an entry with no setpoint_entity is refused
        Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
  PASS  an entry with no max_w is refused
        Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
  PASS  an entry with max_w over the range is refused
        value must be at most 5000.0
  PASS  a JSON STRING where the list belongs is refused
        Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)

== E. the ROLLBACK direction, and the operator's UI ==
  PASS  a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
        ['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
  PASS  Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
        [{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer

== all cases passed ==

exit 0. This is the transcript now committed in
DECISIONS/CFG-04-inverter-roster.md — a real run from today, not the old one.

Note §B2's raw refusal in that run: Missing option 'sign_disagree_w' in root — a third different key across three runs. That non-determinism is the
finding, printed.


The mis-citations, all four

where was now
DECISIONS/… §4, "§B shows both halves" claimed §B showed the inverters refusal says what §B shows (one key varied, refused by name) and what §B2 does not show
DECISIONS/… line 103 transcript Missing option 'meter_freeze_s' in root quoted under the inverters claim the real §B string; whole transcript re-run and replaced
CHANGELOG.md "answers Missing option 'inverters' in root for an options blob that has no such key" "the same block with only inverters removed"
test_inverter.py part-9 comment + part-12 mutation row attributed to the probe generally attributed to §B, and the row records that the probe was blind to that mutation until this review

MUTATION.md gains it as the sixth instance of "the check's data cannot
reach the failure it names"
— the first one found outside the suite, in a
committed evidence artifact, because nothing in that document had ever been
pointed at tools/. Three habits with it: assert on the content of a
refusal; vary one thing from an accepted baseline; and treat a committed
transcript as a check subject to the mutation battery.


Your two non-blocking findings

Rules 5 and 6 named the wrong inverter. Confirmed and fixed. unit_caps()
skips an entry with no usable max_w, so a caps index stops being a roster
index; rule 5 fed caps.index(weakest) to _unit_label() (which indexes the
roster) and rule 6 paired caps[0] with a hard-wired _unit_label(opts, 0).
New unit_caps_indexed() carries the roster index with the cap. On a roster of
[a: max_w="junk", b: 2000, c: 1000] the old rule 5 named inverter-b for
inverter-c's 1000 W and the old rule 6 named inverter-a for inverter-b's
2000 W. Part 10 now checks the fixture really desynchronises the lists, that
both messages name the right unit, and — the counterfactual — that the old
expressions on that same config still evaluate to the wrong names
.

DOCS.md's "safe" downgrade. Corrected. The schema half is true; the add-on
half is not. Rule 9(e) requires the flat setpoint_entity be cleared when a
roster is declared, so a config that downgrades cleanly is by construction one
whose flat actuator is blank. I ran release/1.0's own
validate_startup_config() on that blob:

release/1.0 validator -> 1 refusal(s)
- setpoint_entity is not set, so this add-on has no actuator: every write is a no-op, ...

Recoverable and loud, but an outage until a field is refilled. The note says so
and tells the operator to write the entity down before downgrading.


19/19 green in goodwe-addon-ci.

⚠️ Unchanged and still true: inverter B does not physically exist. Its
3000 W is the marked hypothesis SPIKE-04 will replace, and nothing in this round
measured it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## Fix round pushed — `22c016f` The blocking finding is addressed at the instrument, not at the documents that quoted it. ### What was actually wrong `tools/supervisor_shape_probe.py` §B validated the **reconstructed live options blob**, which is missing many required keys unrelated to this card. Its refusal named whichever of *those* the validator reached first — the committed transcript recorded `meter_freeze_s`, your run printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`. The quoted `Missing option 'inverters' in root` was **never produced by the instrument**. And with a bare `not ok` assertion, deleting `inverters: []` left §B printing **PASS**. ### What §B is now An **isolated counterfactual**: the full shipped `options:` block (expect accept), then the same block with `inverters` removed **and nothing else varying** (expect refusal), asserted on the **key name in the refusal string**. The old case survives as **§B2**, relabelled as an incomplete reconstruction refused for unrelated keys. The probe now also **exits non-zero** on any failed case. --- ## The proof you asked for Both runs against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`, same command, same mounts. The only difference is the one line in `config.yaml`. ### 1. `inverters: []` DELETED from `config.yaml` → the probe FAILS ``` App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7'] Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller) == A. the whole config.yaml through SCHEMA_APP_CONFIG == PASS Supervisor accepts the add-on manifest, version 0.6.0 schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}] options ship inverters -> null == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key == FAIL the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) == B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version == PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement Missing option 'price_provider' in root in GoodWe RS485 Controller (local_goodwe_controller) FAIL the same blob MERGED with these defaults - what App.options actually is Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) ``` ``` == 2 CASE(S) FAILED == - the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed - the same blob MERGED with these defaults - what App.options actually is ``` **exit 1.** `_no_inv` and the shipped defaults are now the same dict, so the accept-half has nothing left to be a baseline for and goes red — which is exactly the signal that ought to appear when the default is gone. §B2's merged half goes red with it, for the same reason. ### 2. Key restored → the probe PASSES ``` App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7'] Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller) == A. the whole config.yaml through SCHEMA_APP_CONFIG == PASS Supervisor accepts the add-on manifest, version 0.6.0 schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}] options ship inverters -> [] == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key == PASS the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller) == B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version == PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller) PASS the same blob MERGED with these defaults - what App.options actually is inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000 == C. AC2: a two-inverter blob an operator would POST == PASS two inverters [{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s == D. what the schema itself refuses, per entry == PASS an entry with no name is refused Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller) PASS an entry with no setpoint_entity is refused Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller) PASS an entry with no max_w is refused Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller) PASS an entry with max_w over the range is refused value must be at most 5000.0 PASS a JSON STRING where the list belongs is refused Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller) == E. the ROLLBACK direction, and the operator's UI == PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse ['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert'] PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer [{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer == all cases passed == ``` **exit 0.** This is the transcript now committed in `DECISIONS/CFG-04-inverter-roster.md` — a real run from today, not the old one. Note §B2's raw refusal in that run: **`Missing option 'sign_disagree_w' in root`** — a third different key across three runs. That non-determinism is the finding, printed. --- ## The mis-citations, all four | where | was | now | |---|---|---| | `DECISIONS/…` §4, "§B shows both halves" | claimed §B showed the `inverters` refusal | says what §B shows (one key varied, refused by name) and what §B2 does **not** show | | `DECISIONS/…` line 103 transcript | `Missing option 'meter_freeze_s' in root` quoted under the `inverters` claim | the real §B string; whole transcript re-run and replaced | | `CHANGELOG.md` | "answers `Missing option 'inverters' in root` for an options blob that has no such key" | "the **same block with only `inverters` removed**" | | `test_inverter.py` part-9 comment + **part-12 mutation row** | attributed to the probe generally | attributed to **§B**, and the row records that the probe was blind to that mutation until this review | `MUTATION.md` gains it as the **sixth** instance of *"the check's data cannot reach the failure it names"* — the first one found **outside the suite**, in a committed evidence artifact, because nothing in that document had ever been pointed at `tools/`. Three habits with it: assert on the **content** of a refusal; vary **one** thing from an **accepted** baseline; and treat a committed transcript as a check subject to the mutation battery. --- ## Your two non-blocking findings **Rules 5 and 6 named the wrong inverter.** Confirmed and fixed. `unit_caps()` skips an entry with no usable `max_w`, so a caps index stops being a roster index; rule 5 fed `caps.index(weakest)` to `_unit_label()` (which indexes the roster) and rule 6 paired `caps[0]` with a hard-wired `_unit_label(opts, 0)`. New `unit_caps_indexed()` carries the roster index with the cap. On a roster of `[a: max_w="junk", b: 2000, c: 1000]` the old rule 5 named **inverter-b** for inverter-c's 1000 W and the old rule 6 named **inverter-a** for inverter-b's 2000 W. Part 10 now checks the fixture really desynchronises the lists, that both messages name the right unit, and — the counterfactual — that the **old expressions on that same config still evaluate to the wrong names**. **`DOCS.md`'s "safe" downgrade.** Corrected. The schema half is true; the add-on half is not. Rule 9(e) requires the flat `setpoint_entity` be cleared when a roster is declared, so a config that downgrades cleanly is by construction one whose flat actuator is blank. I ran `release/1.0`'s own `validate_startup_config()` on that blob: ``` release/1.0 validator -> 1 refusal(s) - setpoint_entity is not set, so this add-on has no actuator: every write is a no-op, ... ``` Recoverable and loud, but an outage until a field is refilled. The note says so and tells the operator to write the entity down before downgrading. --- **19/19 green** in `goodwe-addon-ci`. ⚠️ Unchanged and still true: **inverter B does not physically exist.** Its 3000 W is the marked hypothesis SPIKE-04 will replace, and nothing in this round measured it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 18f052a071 into release/1.0 2026-09-04 11:51:58 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#45