CFG-01 review: widen the rule, refuse the firmware-veto case, fix the stop footgun
Review findings, all confirmed in code before changing anything. THE RULE DID NOT AUTHORISE ITS OWN BEST REFUSAL. The stated test was "makes a safety mechanism inoperative, or makes a stated limit untrue", and a blank setpoint_entity does neither - layer 1 pinning the inverter at 0 W is the failsafe WORKING, as validate.py's own text admitted. The real justification is a second, separate category: the add-on is inert and indistinguishable from healthy. Stated as (a) and (b), with each refusal tagged, and with both halves explicitly bounded - (a) is not "any two numbers that could disagree", (b) is not "anything degraded". Left implicit, the gap is where trivia-creep comes back in. meter_max_age_s ABOVE THE FIRMWARE'S VETO IS NOW REFUSED. The schema allows int(5,300); the firmware's threshold is a compile-time `max_age_s: "30"` and it trips layer 1 at `p1_age_s >= 30`. At 120 the add-on believes a 119 s reading is fresh and keeps commanding while the board has already vetoed and is holding at 0 W - healthy meter, running controller, nothing moving. Structurally identical to the max_w case, and unlike max_w it is settable on this install, so the headline behaviour finally has a live demonstration. That firmware file's own comment says a lower bound on this "belongs in CFG-01, not here". Only ABOVE is refused: below the veto the add-on gives up first, which is fail-safe and visible on its own status page. Scoped to P1 being enabled, because meter_max_age_s governs nothing otherwise. FIRMWARE_INGEST_VETO_S cannot be imported from a compile-time YAML substitution, so it carries a note on the single direction in which it can go stale - and a warning that TEL-06 is editing `ingest_stale`, so a matching integer may stop meaning a matching rule. stop= WAS A LIVE FOOTGUN. It defaulted to None, which made a Controller that cannot stop itself the DEFAULT one. On the late-refusal path that meant: log the banner, return, and leave the process alive forever with no control loop and no exit - the exact "inert but looks healthy" failure this module exists to refuse. Now required and keyword-only, so the broken shape does not construct; test_p1.py's five call sites pass an Event. That whole path had no test and no live demo. It has one now: it drives run_control() against a device that only reports a narrower range once the loop starts, and asserts it refuses, signals, returns, and writes NOTHING - plus the converse, that an agreeing device leads to real writes. This also covers the max_w composition that cannot be reached through the Supervisor on the live install. max_w <= 0 is refused. Schema-blocked in production, but validate_startup_config() is the simulation suite's entry point too, and a non-positive max_w inverts the arbiter's supervised bounds into a contradiction that reads as a safety trip rather than a typo. Verified live 2026-08-28 (backup goodwe_controller-cfg01r2-1787940076): meter_max_age_s 120 refused with a message naming 120, 30, and THE FIRMWARE WINS; no traceback; add-on restored byte-exact and running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc9bd89dca
commit
547d796c5d
@@ -7,8 +7,14 @@ instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||||
cannot leave half a controller running behind a UI.
|
||||
|
||||
It refuses exactly three things, all of which share one property - continuing
|
||||
makes a safety mechanism inoperative or makes a stated limit untrue:
|
||||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||||
limit is untrue - the operator configured a bound and a lower layer silently
|
||||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||||
become unsafe, it becomes a convincing impression of a working controller.
|
||||
|
||||
Four things qualify:
|
||||
|
||||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||||
@@ -25,8 +31,25 @@ makes a safety mechanism inoperative or makes a stated limit untrue:
|
||||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||||
has sent anything to yet.
|
||||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||||
be imported from a compile-time YAML substitution, so it carries a note about
|
||||
the one direction in which it can go stale.
|
||||
|
||||
The predicates for the last group still live in `p1.validate_meter_options()`,
|
||||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||||
than as the typo it is.
|
||||
|
||||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||||
which `build_source()` also calls - one definition, two callers, not two
|
||||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||||
|
||||
Reference in New Issue
Block a user