Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s

CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-04 12:23:50 +02:00
co-authored by Claude Opus 5
13 changed files with 2090 additions and 120 deletions
@@ -0,0 +1,213 @@
# 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.