checks / unit (pull_request) Successful in 4m33s
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
214 lines
13 KiB
Markdown
214 lines
13 KiB
Markdown
# CFG-04 — how a second inverter is declared, and why that shape was measured
|
|
|
|
**Status: DECIDED 2026-09-04 — a list of objects, `inverters:`, with `[]` as
|
|
the shipped default.** The alternative on the table was the `static_profile`
|
|
precedent: a structured value carried as a JSON **string** in a `str?` option.
|
|
Both work. The list wins on three counts that were measured rather than argued,
|
|
and the empty default is not cosmetic — without it the upgrade breaks.
|
|
|
|
Filed with CFG-04. Card 6a99fdf9d381935eddce4467.
|
|
|
|
---
|
|
|
|
## 1. The question, and why it could not be answered from the documentation
|
|
|
|
`config.yaml` used `list(off|ha_dsmr|…)` for enums and `str?` for free text, and
|
|
**there was no list-of-objects anywhere in the file**. `static_profile`
|
|
(`options:` `""`, `schema:` `str?`) carries a whole tariff structure as one JSON
|
|
line, and its own comment says why: *"a JSON string rather than a nested schema,
|
|
deliberately … it survives config.yaml's flat options block"*. That comment is
|
|
evidence that a nested schema was **doubted**, not that it was tried.
|
|
|
|
So the question was not "is this expressible in YAML" but **"does the Supervisor
|
|
this site actually runs accept it"** — and the Supervisor pins the options schema
|
|
to the *installed* version (`docs/pickup.md`), so guessing wrong is not a
|
|
compile error, it is an operator whose add-on will not start after an update
|
|
(`docs/deploy-rollback.md` §4, in the forward direction).
|
|
|
|
## 2. How it was answered
|
|
|
|
`tools/supervisor_shape_probe.py`, committed beside this record, runs
|
|
**Supervisor's own `AppOptions` validator and its own `SCHEMA_APP_CONFIG`** out
|
|
of the release image — `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`
|
|
— against **this repository's `config.yaml`**. 2026.08.0 is the version the live
|
|
install reports (`supervisor_version` in its own backup metadata, 2026-09-03,
|
|
alongside Home Assistant 2026.8.3).
|
|
|
|
Nothing was re-implemented: if the Supervisor changes its rules, the script
|
|
changes its answer. Nothing was touched, either — it reads the file and runs a
|
|
validator in a throwaway container. It does **not** talk to the live Supervisor
|
|
and does not POST options, because the schema conflict is the one thing you do
|
|
not want to discover on a running site.
|
|
|
|
### What it printed, 2026-09-04
|
|
|
|
⚠️ **This transcript was RE-RUN after review, against the same image tag, and
|
|
it is not the one this record first carried.** The first §B validated the
|
|
reconstructed live blob — a blob missing many required keys that have nothing
|
|
to do with this card — and quoted its refusal as evidence about `inverters`.
|
|
It never was: that refusal names whichever unrelated key the validator reaches
|
|
first, and it has printed `meter_freeze_s`, `p1_phase_export_entities` and
|
|
`sign_disagree_w` on three different runs. §B is now the isolated
|
|
counterfactual — the shipped `options:` block, and the same block with
|
|
`inverters` and nothing else removed — and it asserts on the KEY NAME in the
|
|
refusal. The mis-cited half is kept, relabelled, as §B2. See §4.
|
|
|
|
```
|
|
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 ==
|
|
```
|
|
|
|
*(`arch: armv7` deprecation is pre-existing and unrelated.)*
|
|
|
|
## 3. What that establishes, item by item
|
|
|
|
| Claim | Evidence |
|
|
|---|---|
|
|
| A list of objects is a legal `schema:` element | §A — the whole manifest passes `SCHEMA_APP_CONFIG`, whose `SCHEMA_ELEMENT` explicitly permits `[{str: Self}]` |
|
|
| Per-entry types are enforced | §D — `max_w: 9999` refused with *"value must be at most 5000.0"* |
|
|
| A missing required key is refused **by name** | §D — *"Missing option 'name' in inverters"* |
|
|
| A two-inverter blob validates | §C |
|
|
| The operator can edit it in the UI | §E — `UiOptions` renders `{"type": "schema", "multiple": true}` |
|
|
| The `inverters: []` default is what makes a pre-0.6.0 blob validate | §B — the shipped defaults **minus only that key** are refused with *"Missing option 'inverters' in root"*; unmodified they pass |
|
|
| A downgrade drops the key rather than refusing | §E — a schema without `inverters` **drops the key with a warning**; it is not the §B trap in reverse |
|
|
|
|
## 4. ⚠️ The one that decided the default: `inverters: []`
|
|
|
|
§B is the load-bearing measurement, and it is an **isolated counterfactual**:
|
|
exactly one key differs between its two runs. `supervisor/apps/options.py`'s
|
|
`_check_missing_options()` treats a nested list as **required** unless its
|
|
element is an optional scalar — a list whose element is a dict is never
|
|
optional. So the shipped `options:` block validates as it stands, and the same
|
|
block with **only** `inverters` removed is answered with:
|
|
|
|
```
|
|
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
|
```
|
|
|
|
The live install's stored options have no such key, because it did not exist
|
|
when they were written. If `config.yaml` declared `inverters:` in `schema:` and
|
|
**not** in `options:`, then on update `App.test_update_schema()` would log *"new
|
|
schema is not compatible"* and the operator would be left with an add-on that
|
|
will not start — and the schema they would need to fix it with is pinned to the
|
|
version they cannot install.
|
|
|
|
What makes it safe is that `App.options` is
|
|
`deepmerge(config.yaml's options:, the operator's persisted options)`, so a
|
|
shipped `[]` fills the gap before validation ever runs. §B shows the halves that
|
|
carry the claim — the defaults **minus this one key refused, by this key's
|
|
name**, and the unmodified defaults **accepted** — and §B2 shows the merge doing
|
|
its job on a reconstructed pre-0.6.0 blob.
|
|
|
|
⚠️ **What §B2 does NOT show, and what this record used to claim it did.** §B2's
|
|
`LIVE` dict is an incomplete reconstruction: it carries the keys `HANDOVER.md`
|
|
and `docs/deploy-rollback.md` record and no others, so it is missing many
|
|
required keys unrelated to this card. Its RAW half is therefore refused for
|
|
whichever of *those* the validator reaches first — three separate runs have
|
|
printed `meter_freeze_s`, `p1_phase_export_entities` and `sign_disagree_w` —
|
|
and the earlier version of this section quoted that refusal as if it named
|
|
`inverters`. It never did. The string above was produced by §B, where the
|
|
`inverters` key is the only thing that varies.
|
|
|
|
The probe's assertion on that line is on the **key name in the refusal**, not on
|
|
"a refusal happened". With the weaker assertion §B would have gone on printing
|
|
PASS after someone deleted `inverters: []` from `config.yaml` — blind to the one
|
|
regression it is the committed evidence for. It is now checked both ways: with
|
|
the default deleted the probe prints `2 CASE(S) FAILED` and exits 1; restored,
|
|
it prints `all cases passed` and exits 0.
|
|
|
|
The merge strategy was checked too, because it is the other way this could have
|
|
gone wrong: `_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an
|
|
`override` fallback, so **lists override rather than concatenate**. A shipped
|
|
one-element default plus an operator's two-element list would otherwise have
|
|
produced three entries — with inverter A declared twice, aimed at one actuator,
|
|
which is exactly the hazard SAFETY-28 refuses. `[]` makes that unreachable and
|
|
the override rule means even a non-empty default could not have grown a roster.
|
|
|
|
## 5. Why the list, and not the `str?` + JSON precedent
|
|
|
|
The JSON string works — §C of the earlier draft of this probe validated the same
|
|
roster as a `str?` — and if the list had failed it is what would have shipped.
|
|
It loses on three things:
|
|
|
|
1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the
|
|
options **tree** looking for `setpoint_entity` at any depth, and its own
|
|
docstring anticipates this card: *"Add CFG-04's per-inverter config — a list
|
|
of inverters, each with its own `setpoint_entity` — and there are two real
|
|
writers"*. Inside a JSON **string** those entities are invisible to it, so
|
|
the §1.5 two-writers guarantee would have had to be re-implemented against a
|
|
parsed copy — a second place for the census to be wrong.
|
|
2. **No per-field validation.** With `str?` the Supervisor sees one opaque
|
|
string: a typo'd rating, a missing name and a malformed entity id all arrive
|
|
at the add-on identically, and every error message has to be written here.
|
|
§D shows the Supervisor doing that work, in its own words, before the add-on
|
|
starts.
|
|
3. **No UI.** §E renders a repeatable section; a `str?` renders a text box into
|
|
which an operator hand-types JSON. `static_profile` accepts that cost because
|
|
§14 gives it exactly one key and its structure is a tariff document; a roster
|
|
is a list of devices an operator adds one at a time.
|
|
|
|
## 6. What is deliberately NOT in the shape
|
|
|
|
- **No per-entry defaults.** `name`, `setpoint_entity` and `max_w` are
|
|
**required**; the sensors are optional and their absence means *this unit has
|
|
no such sensor*, never *use the other one's*. CFG-03 keeps defaults in exactly
|
|
one place, and a per-entry default would be a second one — worse, it would be
|
|
a per-entry default whose value is another inverter's measurement, which is
|
|
the sibling-grading defect DEV-02 spent a card removing.
|
|
- **No `gain`, `slew_w`, `integrator_max_w` per entry.** §6.2 and ALLOC-01 are
|
|
ONE integrator on P1 above the split. `app/inverter.py`'s module docstring
|
|
carries that argument; the config must not make a second one expressible.
|
|
- **No `inverters` entry for the flat keys.** They ARE the single-inverter
|
|
shape. Declaring both is refused (rule 9e) rather than merged, because a
|
|
config that silently becomes a different config is worse than one that
|
|
refuses to load.
|
|
|
|
## 7. Revisit triggers
|
|
|
|
- **A Supervisor upgrade that changes `AppOptions`.** Re-run the probe with the
|
|
new version tag. The whole point of committing it is that this costs a minute.
|
|
- **SPIKE-04 measuring inverter B.** It supplies a real `max_w` for the second
|
|
entry. It does not change the shape.
|
|
- **A second `Maintenance` FSM.** `validate.py` rule 6 grades
|
|
`maintenance_charge_w` against the first entry of `unit_caps_indexed()`
|
|
because `Controller.maint_unit` is `inverters[0]`. When that stops being true, that line moves with it.
|