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
+16 -5
View File
@@ -1346,6 +1346,17 @@ check("with P1 on the age sensor IS announced",
from app.main import Controller # noqa: E402
def _never():
"""A stop Event these publish-only tests never set.
`stop` is a REQUIRED keyword argument (CFG-01): a Controller that cannot
stop itself used to be the default one, and on the late-refusal path that
meant a live process with no control loop and no exit. These cases only call
publish(), so any Event will do - but they may not skip it.
"""
return asyncio.Event()
class _Store:
data = {}
@@ -1368,14 +1379,14 @@ class _Pub:
pub_off = _Pub()
Controller({"meter_source": "off"}, None, _Store(), pub_off).publish()
Controller({"meter_source": "off"}, None, _Store(), pub_off, stop=_never()).publish()
check("with P1 off, p1_age is absent from the published payload",
"p1_age" not in pub_off.last)
check("...while the normal status keys are still published",
"setpoint" in pub_off.last and "grid" in pub_off.last)
pub_on = _Pub()
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on).publish()
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on, stop=_never()).publish()
check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
check("...as a number, so has_state() becomes true only once we feed it",
isinstance(pub_on.last["p1_age"], float))
@@ -1384,14 +1395,14 @@ check("...as a number, so has_state() becomes true only once we feed it",
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
# the transport that can actually read the meter in this house.
pub_sig = _Pub()
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig).publish()
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig, stop=_never()).publish()
check("with ha_signed selected, p1_age is published too",
"p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float))
# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually
# threshold - every other transport's age measures when the VALUE changed.
pub_hw = _Pub()
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw).publish()
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw, stop=_never()).publish()
check("with homewizard_local selected, p1_age is published",
"p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float))
@@ -1421,7 +1432,7 @@ def p1_rows(ctl):
def _fed(source):
ctl = Controller({"meter_source": source}, None, _Store(), _Pub())
ctl = Controller({"meter_source": source}, None, _Store(), _Pub(), stop=_never())
ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1,
ingest_mono=time.monotonic()))
return ctl