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

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
This commit is contained in:
glenn schrooyen
2026-08-28 19:29:35 +02:00
co-authored by Claude Opus 5
parent 0422127cab
commit fc9bd89dca
5 changed files with 582 additions and 74 deletions
+42
View File
@@ -2,6 +2,48 @@
## 0.4.0
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
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:
- **`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
`write_setpoint()` silently clamping, so the configured ceiling was never the
real one and the only evidence had scrolled off the log. The refusal names the
configured value, the device's value, and which wins.
- **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
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
`meter_max_age_s`. These were already caught, but they only *disabled
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.
The predicates for the last 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
which previously built a transport that could never read.
Deliberately **not** refused, because it is untidy rather than 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 it), a
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
late; refusing would make a slow boot need a human - re-checked once the real
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
A refusal is legible as a refusal: a banner that says in words that this is a
configuration problem and nothing is broken, the numbered problems, no traceback,
and exit status 78 (`EX_CONFIG`).
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
unsigned registers and refuse negatives, so neither fits the meter installed here.