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:
co-authored by
Claude Opus 5
parent
0422127cab
commit
fc9bd89dca
@@ -40,6 +40,7 @@ from .hass import HomeAssistant
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
from .mqtt import MqttPublisher
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||
from . import web
|
||||
|
||||
OPTIONS_PATH = "/data/options.json"
|
||||
@@ -56,11 +57,15 @@ def load_options() -> dict:
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub):
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, stop=None):
|
||||
self.o = opts
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.mqtt = mqtt_pub
|
||||
# Set by run_control() if the startup gate could not see a conflict
|
||||
# because the setpoint device had not appeared yet. See run_control().
|
||||
self.stop_event = stop
|
||||
self.refused: list[str] | None = None
|
||||
|
||||
self.tuning = Tuning(
|
||||
gain=float(opts.get("gain", 0.6)),
|
||||
@@ -303,10 +308,26 @@ class Controller:
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
if entity:
|
||||
self.dev_min, self.dev_max, _ = await self.hass.limits(entity)
|
||||
if self.dev_max is not None and self.tuning.max_w > self.dev_max:
|
||||
self.log_event(
|
||||
f"clamp {self.tuning.max_w:.0f} W exceeds the device maximum "
|
||||
f"{self.dev_max:.0f} W - the device wins")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
||||
# exists to remove: a max_w above the device's rating meant
|
||||
# write_setpoint() silently clamped and the configured ceiling was
|
||||
# never the real one.
|
||||
#
|
||||
# amain() already ran this gate before anything started. It runs
|
||||
# AGAIN here for one case the gate cannot cover: ESPHome is often
|
||||
# not up yet when the add-on boots, so hass.limits() returned
|
||||
# (None, None) and the range checks were skipped rather than
|
||||
# guessed at. This is the first moment the device's real rating is
|
||||
# known. If it conflicts, refuse now - stopping deterministically
|
||||
# through the normal shutdown, which commands 0 W on the way out.
|
||||
problems = validate_startup_config(self.o, self.dev_min, self.dev_max)
|
||||
if problems:
|
||||
refuse(problems)
|
||||
self.refused = problems
|
||||
self.log_event("REFUSED: " + problems[0])
|
||||
if self.stop_event is not None:
|
||||
self.stop_event.set()
|
||||
return
|
||||
|
||||
last_grid = object()
|
||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||
@@ -515,6 +536,26 @@ async def amain() -> None:
|
||||
except Exception as _e: # noqa: BLE001
|
||||
_LOG.error("core api probe failed: %s", _e)
|
||||
|
||||
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
||||
# configuration validate.py rejects - specs §1.5: a config the system
|
||||
# cannot honour is refused at startup, not survived at runtime.
|
||||
#
|
||||
# It sits HERE, before the broker, before the web server and before any
|
||||
# task exists, so a refusal cannot leave half a controller running with
|
||||
# a UI in front of it. Reading the device range is the one thing the
|
||||
# validator cannot do for itself; (None, None) when ESPHome has not come
|
||||
# up yet is not a refusal - run_control() re-checks once the real rating
|
||||
# is known. See validate.py for where the line is drawn and why.
|
||||
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
_dev_min = _dev_max = None
|
||||
if _sp:
|
||||
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
||||
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
||||
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
||||
if _problems:
|
||||
refuse(_problems)
|
||||
raise ConfigRefused(_problems)
|
||||
|
||||
# ⚠️ Status publishing must NEVER be able to stop the controller. A
|
||||
# broken broker, a missing library, an API change in paho - all of it is
|
||||
# observability, and the battery does not care. Caught broadly and on
|
||||
@@ -540,10 +581,10 @@ async def amain() -> None:
|
||||
|
||||
pub = _NoMqtt()
|
||||
|
||||
controller = Controller(opts, hass, store, pub)
|
||||
stop = asyncio.Event()
|
||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||
runner = await web.start(controller, port=8099)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
@@ -570,6 +611,10 @@ async def amain() -> None:
|
||||
await task
|
||||
await runner.cleanup()
|
||||
_LOG.info("stopped")
|
||||
# The late-arriving-device refusal from run_control(). Re-raised only
|
||||
# now, so the wind-down above still ran and the inverter is at 0 W.
|
||||
if controller.refused:
|
||||
raise ConfigRefused(controller.refused)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -577,6 +622,13 @@ def main() -> None:
|
||||
asyncio.run(amain())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except ConfigRefused:
|
||||
# ⚠️ No traceback, on purpose. The banner refuse() already printed says
|
||||
# everything an operator needs, and a stack trace on top of it would say
|
||||
# "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3)
|
||||
# EX_CONFIG, so even a script reading the exit status can tell a bad
|
||||
# config from a bug.
|
||||
raise SystemExit(78)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user