"""Startup configuration validation - the gate before the control loop runs. WHAT THIS IS FOR, in one sentence: some configurations are not "a bit off", they are configurations under which the add-on's own safety machinery does not work, and for those the correct behaviour is to refuse to start rather than to log a line and carry on. ⚠️ THE FAILURE THIS REPLACES. `main.py` used to read the setpoint device's own ±5000 W rating, notice that a configured `max_w` of, say, 8000 W exceeded it, write one WARNING to a log nobody reads, and then run anyway with `write_setpoint()` quietly clamping. The operator's stated ceiling was simply not the ceiling, and the only evidence was a line that had scrolled off. specs §1.5 is explicit that a configuration in which the system cannot honour what it was told is refused at startup, not survived at runtime. WHERE THE LINE IS DRAWN, and why it is not further out. A validator that refuses on trivia gets switched off by the first person it inconveniences, so this one refuses on exactly three things, all of which share one property: **continuing makes a safety mechanism inoperative or makes a stated limit untrue.** 1. `max_w` outside the setpoint device's own rating. The stated limit is untrue. arbiter.py's `supervised` SAFETY claim says the output is bounded by ±max_w; it is not, it is bounded by the device, and the two numbers disagree. Which one wins is a coin toss to anyone reading the config. 2. No `setpoint_entity`. This is a controller with no actuator, and nothing about it looks broken. `write_setpoint()` returns immediately on a blank entity, so: no device rating is ever read (the `device` SAFETY claim is never made at all), and the ESP32 heartbeat - the thing that feeds LAYER 1 - never happens. The add-on serves a UI, publishes MQTT, resolves claims, and moves nothing. It is fail-safe by accident, not by design, and it is indistinguishable from working. 3. A `meter_source` that can never produce a reading. §5.1 makes P1 the only authoritative measurement and deliberately provides no fallback, so a transport that cannot be built means `grid` is None forever: permanent "inputs missing", 0 W, and a status page that reports a meter with a climbing age exactly as it would for a healthy meter nobody has sent anything to yet. The predicates live in `p1.validate_meter_options()`, which is also what `build_source()` uses - one definition, two callers. WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe: - `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the extra headroom buys is more wrong-direction power to unwind after saturation - but the arbiter's `supervised` limit still binds the wire to ±max_w, so nothing out of range ever reaches the inverter. The cost is latency, not amperage. Warned about, not refused. - A missing `soc_entity` / `batt_entity`. `inputs_ok` goes False, the loop commands 0 W, and `checks()` already renders a red NOT READY banner naming the missing entity. Fail-safe AND already loud; a refusal adds nothing. - A `setpoint_entity` that exists in the config but is not found on the Core API at startup. This is the transient case - ESPHome is frequently not up yet when the add-on boots - and refusing here would turn a device that arrives thirty seconds late into a restart loop needing a human. It stays a NOT READY banner. - `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale reading and a 0 W cycle. Warned about in p1.py, not refused. """ import logging from .p1 import validate_meter_options _LOG = logging.getLogger("goodwe.config") # ⚠️ The complete set of option keys through which this add-on can reach an # actuator. specs §1.5 makes "any configuration in which two writers can reach # one actuator" a non-goal, and the way that non-goal is ENFORCED here is by # construction: there is exactly one such key, so a second writer is not # expressible in the options schema at all. sim/inject.py's `two_writers` # scenario asserts on this tuple - if a second write path is ever added, that # scenario goes red and somebody has to decide how the two are arbitrated # BEFORE the config that allows both can ship. ACTUATOR_WRITE_KEYS = ("setpoint_entity",) class ConfigRefused(Exception): """The configuration is wrong. Not "something broke" - see main.py's handler. Carries the list of problems so the caller can render them; str() is the first one, so an unhandled traceback still names something useful. """ def __init__(self, problems: list[str]): self.problems = problems super().__init__(problems[0] if problems else "configuration refused") def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]: """Return the list of reasons this configuration must not be started. Empty list means go. `dev_min`/`dev_max` are the setpoint device's own reported range (`hass.limits()`), or None when the device could not be reached - in which case the range checks are skipped rather than guessed at, for the transient reason in this module's docstring. Pure and side-effect free apart from two warnings, so it can be called from a test or from sim/inject.py without a Home Assistant anywhere near it. """ problems: list[str] = [] # --- 1. an actuator to write to ------------------------------------------ writers = [k for k in ACTUATOR_WRITE_KEYS if str(opts.get(k, "") or "").strip()] if not writers: problems.append( "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. " "It would run, look healthy, and move no energy at all. Set it to " "the ESPHome number entity that carries the setpoint.") # --- 2. the configured ceiling against the device's own rating ----------- max_w = _number(opts, "max_w", 2000.0) if max_w is None: problems.append(f"max_w {opts.get('max_w')!r} is not a number") elif dev_max is not None and max_w > dev_max: problems.append( f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at " f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you " f"configured would silently never happen. Lower max_w to " f"{dev_max:.0f} W or less, or point setpoint_entity at a device that " f"can take {max_w:.0f} W.") elif dev_min is not None and -max_w < dev_min: problems.append( f"max_w is {max_w:.0f} W, so this controller may command " f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less " f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower " f"max_w to {abs(dev_min):.0f} W or less.") # --- 3. a meter that can actually read ----------------------------------- # ⚠️ Not a second copy of these rules - p1.py owns them, because # build_source() has to apply the identical predicates and a drifted copy # would be worse than no check. phases = _number(opts, "meter_phases", 1.0) max_age_s = _number(opts, "meter_max_age_s", 30.0) if phases is None or max_age_s is None: problems.append("meter_phases and meter_max_age_s must both be numbers " f"(got {opts.get('meter_phases')!r} / " f"{opts.get('meter_max_age_s')!r})") else: problems.extend(validate_meter_options(opts, int(phases), max_age_s)) # --- warnings: real, but not worth refusing over ------------------------- integ = opts.get("integrator_max_w") integ_w = None if not integ else _number(opts, "integrator_max_w", None) if integ_w is not None and max_w is not None and integ_w > max_w: _LOG.warning( "integrator_max_w %.0f W is above max_w %.0f W. Not refused - the " "arbiter still bounds the wire to +-max_w - but the only thing the " "extra headroom buys is more wrong-direction power to unwind after " "saturation. 0 (follow max_w) is the recommended value.", integ_w, max_w) return problems def _number(opts: dict, key, default): raw = opts.get(key, default) if raw is None or raw == "": return default try: return float(raw) except (TypeError, ValueError): return None def refuse(problems: list[str]) -> None: """Log a refusal an operator can tell apart from a crash. Does not raise. ⚠️ THE POINT OF THE BANNER. "You configured this wrong" must not read like "it broke". A Python traceback in an add-on log means the same thing to most operators as a segfault - file a bug - whereas this block names the keys, names the fix, and says in words that nothing is broken. main.py catches ConfigRefused and exits 78 (sysexits EX_CONFIG), so no traceback is printed at all and even the exit status distinguishes the two. """ _LOG.error("=" * 72) _LOG.error("REFUSING TO START: the add-on configuration is not usable.") _LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;") _LOG.error("the add-on will start as soon as the settings below are fixed.") _LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >") _LOG.error("Configuration, then start the add-on again.") _LOG.error("-" * 72) for i, problem in enumerate(problems, 1): _LOG.error(" %d. %s", i, problem) _LOG.error("=" * 72)