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:
@@ -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.
|
||||
Reference in New Issue
Block a user