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:
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.
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.
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.
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.
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
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.02026-08-28 20:17:56 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-309read the setpoint device's own +-5000 W rating, noticed a configuredmax_wabove it, wrote one WARNING, and ran anyway withwrite_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 inamain()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:
max_woutside the device's own rating. The arbiter'ssupervisedSAFETY 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.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.meter_sourcethat can never produce a reading. Already caught, but only by disabling ingestion - which leavesgridNone 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_wabovemax_w- the arbiter still bounds the wire to +-max_w, so the cost is unwind latency, not amperage. Warned.soc_entity/batt_entity- fail-safe and already a red NOT READY banner naming the entity.setpoint_entitynot yet on the Core API - ESPHome is routinely late; refusing would make a slow boot need a human. Re-checked inrun_control()the moment the real rating arrives.meter_poll_smargin. Warned.A validator that refuses on trivia gets switched off by the first person it inconveniences, so
test_validate.pyasserts 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 top1.validate_meter_options(), whichbuild_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_dsmrwith only one of its two entities, andmqtt_p1with 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.tgztaken first):and, proving the p1 fold-in refuses at startup rather than merely disabling ingestion:
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_wrefusal could not be triggered on this install, and that is worth a ticket.config.yaml's schema ismax_w: int(100,5000), the Supervisor pins the options schema to the installed version (astore/reloadand arebuildboth leave it cached), and the onlynumberentity 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 bytest_validate.pyand bysim/inject.py'stwo_writers, which calls the shipped validator withmax_w=8000against(-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_writersgate inadmin/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_entitydoes neither. Layer 1 pinning the inverter at 0 W is the failsafe working, whichvalidate.pyitself half-admitted.The rule is now two categories, and each refusal is tagged with the one it rests on:
(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_sabove the firmware's vetoAgreed, and it belongs. Schema allows
int(5,300); the firmware's threshold is a compile-timemax_age_s: "30"used by both its monotonic receipt clock and itsingest_staleveto, which trips layer 1 atp1_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_wcase - 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:
>, not>=- the firmware trips at>= 30andP1Ingestgoes stale at> max_age_s, so 30/30 is the shipped, tested pair with the firmware fractionally stricter by one sample.meter_max_age_sis read by nothing butP1Ingest, and the veto keys off a sensor only published when ingestion runs. Withmeter_source: offit governs nothing, so refusing would be pure trivia.FIRMWARE_INGEST_VETO_S = 30is 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_staledirectly. 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=NonefootgunConfirmed and fixed.
stopdefaulted toNone, 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 becauseamain()always passed an Event, buttest_p1.pywas already constructing Controllers without one.stopis now required and keyword-only, so the broken shape does not construct. The fivetest_p1.pycall 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:stopREFUSEDin the operator-visible event logPlus 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(...)withoutstopraisesTypeError.max_w <= 0Refused. Schema-blocked in production, but
validate_startup_config()is the simulation suite's entry point now and can be handed any dict. A non-positivemax_winverts the arbiter'ssupervisedbounds intolo > 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_wcomposition remains untriggerable through the Supervisor.meter_max_age_sis 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: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.yamlmd5s all match; novalidate.pyon 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.
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