CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s
checks / unit (pull_request) Successful in 4m30s
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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.5.2"
|
||||
version: "0.6.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -39,6 +39,59 @@ options:
|
||||
batt_invert: false
|
||||
setpoint_entity: ""
|
||||
|
||||
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
|
||||
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
|
||||
# it is what every install that upgrades into this version lands on. An empty
|
||||
# list means THE FIVE FLAT KEYS ABOVE DESCRIBE THE ONE INVERTER - same
|
||||
# entities, same rating, same behaviour, no operator action - which is the
|
||||
# only migration story this key was allowed to have. `build_roster()` reads
|
||||
# this and nothing else decides the shape.
|
||||
#
|
||||
# Declared, it REPLACES those five keys rather than extending them. Each entry
|
||||
# names its own actuator, its own rating and its own sensors:
|
||||
#
|
||||
# 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: sensor.goodwe_b_battery_power
|
||||
# max_w: 3000
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
# rating became per-instance precisely so that no inverter is ever graded
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
|
||||
# therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
# writer census refuses it as two writers on one actuator, and if it names a
|
||||
# different one the add-on would run a site quietly smaller than the config
|
||||
# reads. Rule 9 refuses both rather than picking one.
|
||||
#
|
||||
# ⚠️ THE SHAPE WAS MEASURED, NOT ASSUMED. A list of objects is not used
|
||||
# anywhere else in this file, and whether Supervisor's options validator
|
||||
# accepts one was an open question - `static_profile` carries its structure as
|
||||
# a JSON string in a `str?` for exactly that doubt. It was settled by running
|
||||
# Supervisor 2026.08.0's OWN `AppOptions` validator (the version this site
|
||||
# runs) against both candidates: tools/supervisor_shape_probe.py is the
|
||||
# script, DECISIONS/CFG-04-inverter-roster.md is what it printed and why the
|
||||
# list won. The default `[]` is load-bearing in that record: WITHOUT it the
|
||||
# validator answers `Missing option 'inverters' in root` for every existing
|
||||
# options blob, and the upgrade leaves an add-on that cannot start.
|
||||
inverters: []
|
||||
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `off` keeps the original single meter_entity path above, so an existing
|
||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||
@@ -369,6 +422,32 @@ schema:
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
|
||||
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
|
||||
# enforced per entry, a missing required key names the key, a scalar where the
|
||||
# list belongs is refused, and the UI renders it as a repeatable section.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
# no `max_w` would have to borrow a rating, which is the sibling-grading
|
||||
# defect DEV-02 removed. The optional pair is the sensors, whose absence
|
||||
# honestly means "this unit reports none".
|
||||
#
|
||||
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
|
||||
# TWICE under `schema:` - once nested here, once flat below - and a reader
|
||||
# that matches a stripped line takes the nested one FIRST. That is the
|
||||
# first-match trap this card had to fix in three checks; leaving the nesting
|
||||
# first keeps the trap baited, so the fix stays graded rather than assumed.
|
||||
# test_inverter.py part 12 asserts the ordering is still hostile.
|
||||
inverters:
|
||||
- name: str
|
||||
setpoint_entity: str
|
||||
max_w: int(100,5000)
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||
|
||||
Reference in New Issue
Block a user