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:
glenn schrooyen
2026-08-28 20:05:10 +02:00
co-authored by Claude Opus 5
parent fc9bd89dca
commit 547d796c5d
5 changed files with 310 additions and 24 deletions
+26 -3
View File
@@ -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