Files
goodwe-addon/goodwe_controller/app/validate.py
T
glenn schrooyenandClaude Opus 5 ffb8ec6373 MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and
main.py's together" and nothing enforced it - a comment where a check
belonged, in a PR that elsewhere proves it knows the difference.

test_validate.py now reads main.py and maintenance.py directly and asserts
the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000,
main.py's two fallbacks and MaintConfig's dataclass default at 2500. It
records the mismatch rather than demanding it be closed, so it passes today
and goes red if either side moves - including when somebody closes it in
main.py, at which point the check and validate.py's block are deleted
together. Nothing in a file another ticket owns is touched.

The consequence is asserted through the real code as well as the literals:
with the key absent the validator accepts, MaintConfig builds 2500 W against
a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction -
the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the
Supervisor always supplies every key) but fully reachable from sim/inject.py
and the tests, which is where anyone debugging maintenance works.

Also: a CHANGELOG section for the upgrade path, covering both the case where
the Supervisor re-derives the default and the case where it persisted the
old one, since which applies was not verifiable without probing the live
install. And maintenance.py's timeout comment said 13.5 kWh where everything
else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:51:40 +02:00

415 lines
24 KiB
Python

"""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.
5. `sign_disagree_w` at or above `max_w`. [(a)]
The arbiter's `supervised` limit bounds every command to +-max_w, and
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
threshold at or above the ceiling and the command side of the comparison
is unsatisfiable BY CONSTRUCTION: the watchdog can never fire, on any
input, ever. Same shape as 1 and 4 - the operator set a number, a layer
they cannot see makes it mean something else, and nothing says so. Worse
here, because what the status page reports in that state is "sign
watchdog: armed". A disarmed guard that advertises itself as armed is the
one thing this project has decided repeatedly it will not ship.
6. `maintenance_charge_w` above `max_w`. [(a)]
The same shape as 5, and found the same way - by checking a worked example
against what actually ships. maintenance's cheap-window shaping files
`Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the
`supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and
P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate
above the ceiling gives `lo > hi` - the arbiter's clause 3, which it
correctly resolves to 0 W and logs as a CONTRADICTION. Measured against
the arbiter, loop asking +500 W:
max_w=2000 hi=-2500 -> +0 W contradiction=True
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(...)
So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at
or above the charge rate, and the only evidence is a contradiction line
that reads like a bug in the arbiter rather than like the two numbers the
operator set. Equality is fine and deliberately not refused - at
max_w == charge_w the window is a single reachable point, -max_w.
⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked
rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does
not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE,
+discharge_w)`. A SET is clamped by the limits above it, not contradicted
by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's
own explain() says `maintenance -> 2000 W, limited by supervised(max_w)`
on the status page every cycle. That is neither (a) - nothing is silent -
nor (b) - it moves energy and the cycle still completes, just slower
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
It is warned about below, not refused.
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.
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
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.")
# --- 5. a sign watchdog that can actually fire ---------------------------
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
# `abs(commanded_w) > min_w`, and the arbiter holds `abs(commanded_w)` at or
# below max_w, so threshold == max_w is already unsatisfiable. Anything
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
# case only, not "close to the rail".
sign_w = _number(opts, "sign_disagree_w", 300.0)
if sign_w is None:
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and sign_w >= max_w:
problems.append(
f"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
"arbiter never lets a command exceed max_w, and the watchdog needs "
f"a command over {sign_w:.0f} W. It would sit there reporting "
"'armed' on the status page while being incapable of tripping - the "
f"one runaway failure mode this system has actually observed. Lower "
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
"default), or raise max_w.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
# charge_w == max_w leaves the single reachable point -max_w and works;
# only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction.
#
# Not scoped to maintenance_enabled, deliberately. The pair is
# contradictory whether or not the schedule is on, and scoping it would
# leave the trap armed for the one operator action - ticking
# maintenance_enabled - that cannot possibly be expected to change what a
# charge rate means.
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
# equal to it - that is the whole point of AC3. It is reachable only when a
# caller hands over a dict with the key missing (sim/inject.py, a test); the
# Supervisor always supplies every key from config.yaml, so on a real
# install the argument is always present.
#
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
# `main.py` still falls back to 2500 in two places
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
# rule at 2000 and then built by main.py at 2500 - the contradiction this
# rule exists to catch, in the one case it cannot see. It is unreachable on
# a Supervisor install and those files are another ticket's right now.
# THE REAL FIX is one source of truth for option defaults; until then the
# divergence is PINNED BY A CHECK rather than by this comment - see
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
# the mismatch as it stands and goes red if EITHER side moves, including
# when somebody closes it in main.py, at which point this block and that
# check both get deleted.
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
elif max_w is not None and max_w > 0 and charge_w > max_w:
problems.append(
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
"never happen: the shaping asks the arbiter for at most "
f"{-charge_w:.0f} W while the supervised limit allows no less than "
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
"THE CEILING WINS, and what you would see is an ARBITER "
"CONTRADICTION line that reads like a bug rather than like these "
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
f"or raise max_w to at least {charge_w:.0f} W.")
# --- 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)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
# clamped by the limits above it rather than contradicted by them. So this
# drains at max_w instead of the configured rate, the arbiter says so in
# words on the status page every cycle, and the cycle still completes -
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
# energy, so it is neither (a) nor (b): warned, not refused.
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
"the drain phase SETS the setpoint and the arbiter clamps it to "
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
"nothing is silent and the cycle still completes. But it drains at "
"%.0f W, not the %.0f W you configured, and the drain phase has a "
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, max_w, max_w, max_w, disch_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)