CFG-01: refuse to start on a configuration that cannot be honoured #6

Merged
admin merged 2 commits from CFG-01 into release/1.0 2026-08-28 20:17:56 +02:00
Owner

CFG-01 AC2. The add-on refuses to start on a configuration it cannot honour, instead of logging a line and clamping quietly.

The behaviour this removes

main.py:306-309 read the setpoint device's own +-5000 W rating, noticed a configured max_w above it, wrote one WARNING, and ran anyway with write_setpoint() silently clamping. The operator's stated ceiling was never the real ceiling and the only evidence had scrolled off the log. specs 1.5 is explicit that such a configuration is refused at startup, not survived at runtime.

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.

Where the line is drawn

Refuses exactly three things, all sharing one property - continuing makes a safety mechanism inoperative or makes a stated limit untrue:

  1. max_w outside the device's own rating. The arbiter's supervised SAFETY claim says the output is bounded by +-max_w; it is not, it is bounded by the device, and which wins is a coin toss to anyone reading the config.
  2. No setpoint_entity. A controller with no actuator: every write is a no-op, the device rating is never read, and the ESP32 heartbeat that holds off layer 1 never happens. It ran, looked healthy, and moved nothing.
  3. A meter_source that can never produce a reading. Already caught, but only by disabling ingestion - which leaves grid None forever: permanent inputs-missing, 0 W, and a status page indistinguishable from a healthy meter nobody has fed yet.

Deliberately not refused, because untidy is not unsafe:

  • integrator_max_w above max_w - the arbiter still bounds the wire to +-max_w, so the cost is unwind latency, not amperage. Warned.
  • A missing soc_entity / batt_entity - fail-safe and already a red NOT READY banner naming the entity.
  • A setpoint_entity not yet on the Core API - ESPHome is routinely late; refusing would make a slow boot need a human. Re-checked in run_control() the moment the real rating arrives.
  • A thin-but-nonzero meter_poll_s margin. Warned.

A validator that refuses on trivia gets switched off by the first person it inconveniences, so test_validate.py asserts the non-refusals as hard as the refusals.

One validation entry point (AC4)

p1.py's poll/age check and its siblings are folded in, not duplicated. They move to p1.validate_meter_options(), which build_source() also calls - one definition, two callers with two different consequences (refuse to start / return None). build_source()'s guard is now unreachable from the add-on and stays as defence in depth for direct callers. Two transports gained the check the other two already had: ha_dsmr with only one of its two entities, and mqtt_p1 with a blank topic.

Refusal vs crash (AC3)

A banner that says in words that this is a configuration problem and nothing is broken, the numbered problems, no traceback, and exit 78 (EX_CONFIG).

Live evidence - verified by setting it, not by reading the code

Against the running add-on on 2026-08-28 (backup /root/goodwe_controller-cfg01-1787937610.tgz taken first):

19:26:34 ERROR goodwe.config: ====================================================
19:26:34 ERROR goodwe.config: REFUSING TO START: the add-on configuration is not usable.
19:26:34 ERROR goodwe.config: This is a CONFIGURATION problem, not a crash. Nothing is broken;
19:26:34 ERROR goodwe.config: the add-on will start as soon as the settings below are fixed.
19:26:34 ERROR goodwe.config: ----------------------------------------------------
19:26:34 ERROR goodwe.config:   1. setpoint_entity is not set, so this add-on has no
    actuator: every write is a no-op, the device's own rating is never read, and
    the ESP32 heartbeat that holds off the layer-1 failsafe never happens...
19:26:34 ERROR goodwe.config: ====================================================

and, proving the p1 fold-in refuses at startup rather than merely disabling ingestion:

19:26:54 ERROR goodwe.config:   1. meter_poll_s 30.0 is not under meter_max_age_s 30.0
    - every reading would go stale before the next poll

Both stopped the add-on cleanly with no stack trace. A good start logs the real device range - setpoint device number.goodwe_master_stage1_goodwe_setpoint_w reports range -5000.0...5000.0 W.

The max_w refusal could not be triggered on this install, and that is worth a ticket. config.yaml's schema is max_w: int(100,5000), the Supervisor pins the options schema to the installed version (a store/reload and a rebuild both leave it cached), and the only number entity on the master board is the +-5000 W setpoint. So the conflicting value is unsettable here without a version bump this session was not allowed to leave behind. The path is covered by test_validate.py and by sim/inject.py's two_writers, which calls the shipped validator with max_w=8000 against (-5000, 5000) and asserts the message names both values. Note the schema hardcodes this inverter's rating - a 3600 W install would sail past it, which is exactly why the runtime check has to exist.

Add-on ends the session on 0.4.0, restored byte-exact from the backup, started, error-free.

Closes CFG-01 AC2, DRIVER-07's surviving AC, ARB-01 AC4, MAINT-02 AC5 residue.

Paired with the two_writers gate in admin/goodwe.

Generated with Claude Code

https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa


Review round 2

The rule statement did not authorise its own best refusal

Fair, and the most useful thing in the review. 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, which validate.py itself half-admitted.

The rule is now two categories, and each refusal is tagged with the one it rests on:

  • (a) a stated limit is untrue - the operator configured a bound, a lower layer silently enforces a different one, and nothing says which won.
  • (b) the add-on is inert and indistinguishable from healthy - it starts, serves its UI, reports green, and moves no energy, with no symptom separating it from a correctly-idle system.

(b) is not a special case of (a), and the file now says so explicitly. Nothing becomes unsafe; it becomes a convincing impression of a working controller. Both halves are bounded in the text, because an unbounded version is how trivia-creep returns: (a) is not "any two numbers that could disagree", it is a bound the operator set being overridden by a layer they cannot see; (b) is not "anything degraded", it is inert and silent - if a red NOT READY banner already names the problem, (b) does not apply.

New refusal: meter_max_age_s above the firmware's veto

Agreed, and it belongs. Schema allows int(5,300); the firmware's threshold is a compile-time max_age_s: "30" used by both its monotonic receipt clock and its ingest_stale veto, which trips layer 1 at p1_age_s >= 30. At 120 the add-on treats a 119 s reading as fresh and keeps commanding while the board has already vetoed and is holding at 0 W.

Structurally identical to the max_w case - category (a) - and it also hits (b). That firmware file's own comment already says a lower bound on this "belongs in CFG-01, not here".

Scope decisions:

  • Only above the veto. Below it the add-on gives up first: fail-safe, and visible on its own status page. Strictly >, not >= - the firmware trips at >= 30 and P1Ingest goes stale at > max_age_s, so 30/30 is the shipped, tested pair with the firmware fractionally stricter by one sample.
  • Only when P1 ingestion is on. meter_max_age_s is read by nothing but P1Ingest, and the veto keys off a sensor only published when ingestion runs. With meter_source: off it governs nothing, so refusing would be pure trivia.
  • FIRMWARE_INGEST_VETO_S = 30 is a mirror of a constant that cannot be imported - it is a compile-time YAML substitution baked into the image, so nothing at runtime can ask the board what it was built with. The comment records why the copy is acceptable: it can only ever be wrong in the safe direction for this check.

⚠️ TEL-06 coupling, flagged not worked around. TEL-06 is editing ingest_stale directly. If it changes what the veto means - thresholding arrival rather than the sensor's value - this constant's justification needs re-reading, not just its number. That warning is in the code next to the constant. I have not touched their branch.

The stop=None footgun

Confirmed and fixed. stop 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, no exit and no further output - the exact category (b) failure this module exists to refuse. Latent because amain() always passed an Event, but test_p1.py was already constructing Controllers without one.

stop is now required and keyword-only, so the broken shape does not construct. The five test_p1.py call sites pass an Event with a comment saying why they may not skip it.

The path nothing tested

Also fixed, and this turned out to be the most valuable new test. run_control()'s re-check now has coverage that drives it against a device reporting a narrower range only once the loop has started - the ESPHome-is-late case - and asserts it:

  • refuses, naming both values
  • signals stop
  • returns instead of looping forever
  • wrote nothing - a single write would mean it commanded the inverter under a config it had just declared unusable
  • recorded REFUSED in the operator-visible event log

Plus the converse: an agreeing device leads to real writes and no shutdown signal. This incidentally covers the max_w-above-device-rating composition that cannot be reached through the Supervisor on the live install.

And a test that Controller(...) without stop raises TypeError.

max_w <= 0

Refused. Schema-blocked in production, but validate_startup_config() is the simulation suite's entry point now and can be handed any dict. A non-positive max_w inverts the arbiter's supervised bounds into lo > hi, which is the contradiction it resolves to 0 W - so it would present as a safety trip rather than as the typo it is. The message says that.

Live evidence - the headline behaviour, finally demonstrated

The max_w composition remains untriggerable through the Supervisor. meter_max_age_s is not, and it is the same fault, so the thing this PR is built around now has a live demonstration on the real add-on:

20:02:32 ERROR goodwe.config:   1. meter_max_age_s is 120 s but the ESP32 firmware
    vetoes any P1 sample at 30 s and forces the layer-1 failsafe - THE FIRMWARE
    WINS, and it wins silently. This add-on would treat a 119 s reading as fresh
    and keep commanding, while the inverter sat at 0 W with a healthy-looking
    meter on the status page. Lower meter_max_age_s to 30 s or less (30 s is the
    tested value), or rebuild the firmware with a matching max_age_s and update
    validate.FIRMWARE_INGEST_VETO_S.

A configured limit, silently overridden by a lower layer that wins, refused at startup with both numbers named. No traceback, exit 78.

Backup goodwe_controller-cfg01r2-1787940076.tgz. Outage ~92 s across two rebuilds. Restored byte-exact (main.py, mqtt.py, p1.py, config.yaml md5s all match; no validate.py on the live tree) and the stored options compare identical to the pre-session snapshot, key by key. Add-on ends on 0.4.0, started, zero WARNING/ERROR since restore.

Battery was SoC 10% / Standby / no PV throughout, so grid sits at house load (~575 W) and the outage moved no energy that was going to move anyway - the same state as before the session started.

All five test suites green.

**CFG-01 AC2.** The add-on refuses to start on a configuration it cannot honour, instead of logging a line and clamping quietly. ### The behaviour this removes `main.py:306-309` read the setpoint device's own +-5000 W rating, noticed a configured `max_w` above it, wrote one WARNING, and ran anyway with `write_setpoint()` silently clamping. The operator's stated ceiling was never the real ceiling and the only evidence had scrolled off the log. specs 1.5 is explicit that such a configuration is refused at startup, not survived at runtime. `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. ### Where the line is drawn Refuses exactly three things, all sharing one property - *continuing makes a safety mechanism inoperative or makes a stated limit untrue*: 1. **`max_w` outside the device's own rating.** The arbiter's `supervised` SAFETY claim says the output is bounded by +-max_w; it is not, it is bounded by the device, and which wins is a coin toss to anyone reading the config. 2. **No `setpoint_entity`.** A controller with no actuator: every write is a no-op, the device rating is never read, and the ESP32 heartbeat that holds off **layer 1** never happens. It ran, looked healthy, and moved nothing. 3. **A `meter_source` that can never produce a reading.** Already caught, but only by *disabling ingestion* - which leaves `grid` None forever: permanent inputs-missing, 0 W, and a status page indistinguishable from a healthy meter nobody has fed yet. Deliberately **not** refused, because untidy is not unsafe: - `integrator_max_w` above `max_w` - the arbiter still bounds the wire to +-max_w, so the cost is unwind latency, not amperage. **Warned.** - A missing `soc_entity` / `batt_entity` - fail-safe *and* already a red NOT READY banner naming the entity. - A `setpoint_entity` not yet on the Core API - ESPHome is routinely late; refusing would make a slow boot need a human. Re-checked in `run_control()` the moment the real rating arrives. - A thin-but-nonzero `meter_poll_s` margin. **Warned.** A validator that refuses on trivia gets switched off by the first person it inconveniences, so `test_validate.py` asserts the *non*-refusals as hard as the refusals. ### One validation entry point (AC4) `p1.py`'s poll/age check and its siblings are **folded in**, not duplicated. They move to `p1.validate_meter_options()`, which `build_source()` also calls - one definition, two callers with two different consequences (refuse to start / return None). `build_source()`'s guard is now unreachable from the add-on and stays as defence in depth for direct callers. Two transports gained the check the other two already had: `ha_dsmr` with only one of its two entities, and `mqtt_p1` with a blank topic. ### Refusal vs crash (AC3) A banner that says in words that this is a configuration problem and nothing is broken, the numbered problems, **no traceback**, and exit **78** (`EX_CONFIG`). ### Live evidence - verified by setting it, not by reading the code Against the running add-on on 2026-08-28 (backup `/root/goodwe_controller-cfg01-1787937610.tgz` taken first): ``` 19:26:34 ERROR goodwe.config: ==================================================== 19:26:34 ERROR goodwe.config: REFUSING TO START: the add-on configuration is not usable. 19:26:34 ERROR goodwe.config: This is a CONFIGURATION problem, not a crash. Nothing is broken; 19:26:34 ERROR goodwe.config: the add-on will start as soon as the settings below are fixed. 19:26:34 ERROR goodwe.config: ---------------------------------------------------- 19:26:34 ERROR goodwe.config: 1. setpoint_entity is not set, so this add-on has no actuator: every write is a no-op, the device's own rating is never read, and the ESP32 heartbeat that holds off the layer-1 failsafe never happens... 19:26:34 ERROR goodwe.config: ==================================================== ``` and, proving the p1 fold-in refuses at *startup* rather than merely disabling ingestion: ``` 19:26:54 ERROR goodwe.config: 1. meter_poll_s 30.0 is not under meter_max_age_s 30.0 - every reading would go stale before the next poll ``` Both stopped the add-on cleanly with no stack trace. A good start logs the real device range - `setpoint device number.goodwe_master_stage1_goodwe_setpoint_w reports range -5000.0...5000.0 W`. **The `max_w` refusal could not be triggered on this install, and that is worth a ticket.** `config.yaml`'s schema is `max_w: int(100,5000)`, the Supervisor **pins the options schema to the installed version** (a `store/reload` and a `rebuild` both leave it cached), and the only `number` entity on the master board is the +-5000 W setpoint. So the conflicting value is unsettable here without a version bump this session was not allowed to leave behind. The path is covered by `test_validate.py` and by `sim/inject.py`'s `two_writers`, which calls the shipped validator with `max_w=8000` against `(-5000, 5000)` and asserts the message names both values. Note the schema hardcodes *this* inverter's rating - a 3600 W install would sail past it, which is exactly why the runtime check has to exist. Add-on ends the session on **0.4.0**, restored byte-exact from the backup, started, error-free. Closes CFG-01 AC2, DRIVER-07's surviving AC, ARB-01 AC4, MAINT-02 AC5 residue. Paired with the `two_writers` gate in `admin/goodwe`. Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa --- ## Review round 2 ### The rule statement did not authorise its own best refusal Fair, and the most useful thing in the review. 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*, which `validate.py` itself half-admitted. The rule is now two categories, and each refusal is tagged with the one it rests on: - **(a) a stated limit is untrue** - the operator configured a bound, a lower layer silently enforces a different one, and nothing says which won. - **(b) the add-on is inert and indistinguishable from healthy** - it starts, serves its UI, reports green, and moves no energy, with no symptom separating it from a correctly-idle system. **(b) is not a special case of (a)**, and the file now says so explicitly. Nothing becomes unsafe; it becomes a convincing impression of a working controller. Both halves are bounded in the text, because an unbounded version is how trivia-creep returns: (a) is not "any two numbers that could disagree", it is a bound the operator *set* being overridden by a layer they cannot see; (b) is not "anything degraded", it is inert **and** silent - if a red NOT READY banner already names the problem, (b) does not apply. ### New refusal: `meter_max_age_s` above the firmware's veto Agreed, and it belongs. Schema allows `int(5,300)`; the firmware's threshold is a compile-time `max_age_s: "30"` used by both its monotonic receipt clock and its `ingest_stale` veto, which trips layer 1 at `p1_age_s >= 30`. At 120 the add-on treats a 119 s reading as fresh and keeps commanding while the board has already vetoed and is holding at 0 W. Structurally identical to the `max_w` case - category (a) - and it also hits (b). That firmware file's own comment already says a lower bound on this *"belongs in CFG-01, not here"*. Scope decisions: - **Only *above* the veto.** Below it the add-on gives up first: fail-safe, and visible on its own status page. Strictly `>`, not `>=` - the firmware trips at `>= 30` and `P1Ingest` goes stale at `> max_age_s`, so 30/30 is the shipped, tested pair with the firmware fractionally stricter by one sample. - **Only when P1 ingestion is on.** `meter_max_age_s` is read by nothing but `P1Ingest`, and the veto keys off a sensor only published when ingestion runs. With `meter_source: off` it governs nothing, so refusing would be pure trivia. - **`FIRMWARE_INGEST_VETO_S = 30` is a mirror of a constant that cannot be imported** - it is a compile-time YAML substitution baked into the image, so nothing at runtime can ask the board what it was built with. The comment records why the copy is acceptable: it can only ever be wrong in the *safe* direction for this check. ⚠️ **TEL-06 coupling, flagged not worked around.** TEL-06 is editing `ingest_stale` directly. If it changes what the veto *means* - thresholding arrival rather than the sensor's value - this constant's justification needs re-reading, not just its number. That warning is in the code next to the constant. I have not touched their branch. ### The `stop=None` footgun Confirmed and fixed. `stop` 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, no exit and no further output - the exact category (b) failure this module exists to refuse. Latent because `amain()` always passed an Event, but `test_p1.py` was already constructing Controllers without one. `stop` is now **required and keyword-only**, so the broken shape does not construct. The five `test_p1.py` call sites pass an Event with a comment saying why they may not skip it. ### The path nothing tested Also fixed, and this turned out to be the most valuable new test. `run_control()`'s re-check now has coverage that drives it against a device reporting a **narrower range only once the loop has started** - the ESPHome-is-late case - and asserts it: - refuses, naming both values - signals `stop` - **returns** instead of looping forever - **wrote nothing** - a single write would mean it commanded the inverter under a config it had just declared unusable - recorded `REFUSED` in the operator-visible event log Plus the converse: an agreeing device leads to real writes and no shutdown signal. This incidentally covers the `max_w`-above-device-rating **composition** that cannot be reached through the Supervisor on the live install. And a test that `Controller(...)` without `stop` raises `TypeError`. ### `max_w <= 0` Refused. Schema-blocked in production, but `validate_startup_config()` is the simulation suite's entry point now and can be handed any dict. A non-positive `max_w` inverts the arbiter's `supervised` bounds into `lo > hi`, which is the contradiction it resolves to 0 W - so it would present as a safety trip rather than as the typo it is. The message says that. ### Live evidence - the headline behaviour, finally demonstrated The `max_w` composition remains untriggerable through the Supervisor. **`meter_max_age_s` is not**, and it is the same fault, so the thing this PR is built around now has a live demonstration on the real add-on: ``` 20:02:32 ERROR goodwe.config: 1. meter_max_age_s is 120 s but the ESP32 firmware vetoes any P1 sample at 30 s and forces the layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This add-on would treat a 119 s reading as fresh and keep commanding, while the inverter sat at 0 W with a healthy-looking meter on the status page. Lower meter_max_age_s to 30 s or less (30 s is the tested value), or rebuild the firmware with a matching max_age_s and update validate.FIRMWARE_INGEST_VETO_S. ``` A configured limit, silently overridden by a lower layer that wins, refused at startup with both numbers named. No traceback, exit 78. Backup `goodwe_controller-cfg01r2-1787940076.tgz`. Outage ~92 s across two rebuilds. Restored **byte-exact** (`main.py`, `mqtt.py`, `p1.py`, `config.yaml` md5s all match; no `validate.py` on the live tree) and the stored options compare **identical** to the pre-session snapshot, key by key. Add-on ends on **0.4.0**, started, zero WARNING/ERROR since restore. Battery was SoC 10% / Standby / no PV throughout, so grid sits at house load (~575 W) and the outage moved no energy that was going to move anyway - the same state as before the session started. All five test suites green.
admin added 1 commit 2026-08-28 19:31:52 +02:00
main.py used to read the setpoint device's own +-5000 W rating, notice a
configured max_w above it, log one WARNING, and run anyway with
write_setpoint() quietly clamping. The operator's stated ceiling was never
the real ceiling and the only evidence had scrolled off the log. specs
§1.5 is explicit that such a config is refused at startup, not survived at
runtime.

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 sharing one
property - continuing makes a safety mechanism inoperative or makes a
stated limit untrue:

  1. max_w outside the setpoint device's own rating.
  2. No setpoint_entity: every write is a no-op, the device rating is
     never read, and the ESP32 heartbeat that holds off the layer-1
     failsafe never happens. It ran, looked healthy, and moved nothing.
  3. A meter_source that can never produce a reading. These were already
     caught, but they only DISABLED ingestion, which leaves grid None
     forever: permanent inputs-missing, 0 W, and a status page
     indistinguishable from a healthy meter nobody has fed yet.

Those last predicates move into p1.validate_meter_options(), which
build_source() also calls - one definition, two callers, not two
competing validators. ha_dsmr with half its entities and mqtt_p1 with a
blank topic gained the check the other two transports already had.

Deliberately not refused, because untidy is not unsafe: integrator_max_w
above max_w (the arbiter still bounds the wire to +-max_w, so the cost is
unwind latency - warned), a missing soc/batt entity (fail-safe and
already a red NOT READY banner), a setpoint entity that has not appeared
on the Core API yet (ESPHome is routinely late; re-checked in
run_control() once the real rating arrives), and a thin meter_poll_s
margin.

A refusal is legible as one: a banner saying in words that this is
configuration and nothing is broken, numbered problems, no traceback, and
exit 78 (EX_CONFIG).

Verified on the live add-on 2026-08-28, not by reading the code: a blank
setpoint_entity and a meter_poll_s equal to meter_max_age_s each stopped
the add-on with the banner and no stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
admin added 1 commit 2026-08-28 20:05:12 +02:00
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
admin merged commit e2e331ea54 into release/1.0 2026-08-28 20:17:56 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#6