"""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 only where **continuing produces one of two outcomes:** (a) **a stated limit is untrue** - the operator configured a bound, a lower layer silently enforces a different one, and nothing says which won; or (b) **the add-on is inert and indistinguishable from healthy** - it starts, serves its UI, reports green, and moves no energy, with no symptom that separates it from a correctly-idle system. ⚠️ (b) IS NOT A SPECIAL CASE OF (a), and an earlier draft of this file wrongly implied it was by claiming every refusal here "makes a safety mechanism inoperative". It does not: when `setpoint_entity` is blank, layer 1 pinning the inverter at 0 W is the failsafe WORKING. The problem is not that the system becomes unsafe, it is that it becomes a very convincing impression of a working controller - and an operator has no way to tell. That is its own reason to refuse, and stating it as one is what stops the rule being stretched later to cover mere untidiness. Both halves have to stay narrow. (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. 1. `max_w` outside the setpoint device's own rating. [(a)] 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`. [(b)] 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. Layer 1 then holds the inverter at 0 W, which is the failsafe working exactly as designed - this is (b), not (a). Nothing is unsafe; it is simply impossible to tell from the outside that the thing is doing nothing. 3. A `meter_source` that can never produce a reading. [(b)] §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. 4. `meter_max_age_s` above the firmware's own ingest veto. [(a)] The purest example of (a) in the system, and structurally the same fault as 1. The add-on's schema accepts `int(5,300)`. The firmware's threshold is a COMPILE-TIME SUBSTITUTION - `max_age_s: "30"` in goodwe-master-stage4-failsafe.yaml - used by both its monotonic receipt clock and its `ingest_stale` veto, which trips layer 1 on `p1_age_s >= 30`. Set 120 and the add-on believes a 119 s reading is fresh and keeps commanding into it, while the firmware vetoed at 30 s and is holding the inverter at 0 W. Status page: healthy meter, running controller, nothing moving, and no line anywhere saying the number you set was overruled. That firmware file's own comment says a lower bound on this "belongs in CFG-01, not here" - so here it is. 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 is_enabled, validate_meter_options _LOG = logging.getLogger("goodwe.config") # ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware # threshold is a compile-time YAML substitution baked into the ESP32 image # (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at # runtime can ask the board what value it was built with. This number is a copy, # and a copy of a constant in another repo is a thing that goes stale. # # What makes that acceptable here: the copy can only ever be WRONG IN THE SAFE # DIRECTION for the check below. If the firmware's veto is raised above 30, this # refuses some configs that would in fact have worked - annoying, visible # immediately, and fixed by editing one line. It cannot start accepting a config # the firmware vetoes unless the firmware's number goes DOWN, which would be a # tightening nobody does silently. # # ⚠️ TEL-06 IS EDITING `ingest_stale` DIRECTLY. If that ticket changes what the # veto MEANS - e.g. thresholding arrival rather than the sensor's value - this # constant's justification has to be re-read, not just its number. Do not assume # a matching integer means a matching rule. FIRMWARE_INGEST_VETO_S = 30.0 # ⚠️ 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") # ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so # the check is not for the Supervisor path - it is because # validate_startup_config() is now also sim/inject.py's entry point and can # be handed any dict at all. A max_w of 0 or below inverts the arbiter's # `supervised` bounds (lo=-max_w becomes the HIGHER number), and a limit # claim whose lo exceeds its hi is exactly the contradiction the arbiter # resolves to 0 W - i.e. it would look like a safety trip rather than the # nonsense config it is. elif max_w <= 0: problems.append( f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, " "not a signed setpoint. At or below zero the arbiter's supervised " f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a " "contradiction it resolves to 0 W, so the controller would look " "like it was tripping rather than misconfigured.") 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)) # --- 4. the staleness window the FIRMWARE will actually enforce ----------- # ⚠️ Only when P1 ingestion is on, and that is a real scope limit rather than # caution: meter_max_age_s is read by nothing but P1Ingest, and the veto it # collides with keys off `p1_age_s.has_state()`, which is only ever published # when ingestion is running (see Controller.publish()). With meter_source off # there is no sensor, the veto is inert, and the number governs nothing. # # Strictly greater, not >=: the firmware trips at `>= 30` and P1Ingest goes # stale at `> max_age_s`, so 30 against 30 has the firmware fractionally # stricter by one sample. That is the shipped default and the tested pair. # BELOW 30 is fine and deliberately not refused - the add-on then gives up # first, which is fail-safe and visible on its own status page. Only ABOVE is # the silent case, where the add-on keeps commanding into a reading the board # has already rejected. if (is_enabled(opts) and max_age_s is not None and max_age_s > FIRMWARE_INGEST_VETO_S): problems.append( f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes " f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the " "layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This " f"add-on would treat a {max_age_s - 1:.0f} 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 " f"{FIRMWARE_INGEST_VETO_S:.0f} 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.") # --- 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)