checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
627 lines
37 KiB
Python
627 lines
37 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.
|
|
|
|
7. A price configuration that cannot mean what it says. [(a)]
|
|
Three cases, all in `_price_problems()`: an unknown `price_provider` id
|
|
(refused rather than defaulted to 'off', per the §14 warning box);
|
|
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
|
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
|
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
|
documented offline fallback into a permanent no-price-data condition
|
|
reached by accident. Same shape as 1, 4 and 5 every time - a source the
|
|
operator chose, overridden by a layer they cannot see.
|
|
|
|
⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4
|
|
makes no-price-data a supported state, and §9.2 says a large share of the
|
|
installed base is on a fixed or static tariff. Refusing to start without a
|
|
price feed would lock those sites out of the add-on entirely.
|
|
|
|
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
|
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
|
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
|
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
|
own cut-out and enforces it regardless. Set the EMS floor at or below that
|
|
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
|
|
the discharge, so the add-on carries an energy backstop that is incapable
|
|
of binding while its status page reports one - rule 5's "a disarmed guard
|
|
that advertises itself as armed", on the guard whose failure mode is a pack
|
|
that is quietly never available when it is wanted.
|
|
|
|
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
|
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
|
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
|
|
limit, which is the entire thing this option exists to prevent. That is not
|
|
"close to the rail", it is the disarmed case with an extra step. Rule 6
|
|
permits equality because equality there leaves one reachable point that
|
|
works; here it leaves none.
|
|
|
|
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.
|
|
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
|
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
|
every ordering of the two is a legitimate site configuration. Refusing on
|
|
an ordering would be inventing a relationship the system does not have.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from .p1 import is_enabled, validate_meter_options
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .price import PROVIDERS
|
|
from .static_tariff import ProfileInvalid, parse_static_profile
|
|
|
|
_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.")
|
|
|
|
# --- 7. two price sources on one site ------------------------------------
|
|
problems.extend(_price_problems(opts))
|
|
|
|
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
|
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
|
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
|
|
# comment documents a LIVE divergence in this repo caused by typing 2000
|
|
# here and 2500 in main.py; this card refuses to add a second instance of
|
|
# it. The dict is the single in-code mirror, pinned to config.yaml by
|
|
# test_peakguard.py, and main.py reads the same one.
|
|
soc_floor = _number(opts, "peak_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
|
bms_floor = _number(opts, "bms_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
|
if soc_floor is None or bms_floor is None:
|
|
problems.append(
|
|
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
|
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
|
elif soc_floor <= bms_floor:
|
|
problems.append(
|
|
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
|
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
|
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
|
"the pack's protection rather than yours. The guard would report a "
|
|
"SoC floor on its status page while being incapable of ever being "
|
|
"the thing that stops the discharge, and the symptom of that is a "
|
|
"battery repeatedly sitting at the BMS limit with no record of why "
|
|
f"- which looks exactly like the SoC drift the maintenance cycle "
|
|
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
|
f"(the shipped default is "
|
|
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
|
"bms_soc_floor if it does not match this pack.")
|
|
|
|
# --- 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 _price_problems(opts: dict) -> list[str]:
|
|
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
|
|
|
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
|
will be: §11.4 makes no-price-data a first-class supported state (static
|
|
profile, provisional plan, no arbitrage), and an add-on that will not start
|
|
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
|
share of the installed base, per §9.2 - cannot run at all.
|
|
|
|
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
|
two notices), and every refusal is still case (a) from this module's
|
|
docstring: a source the operator SET being overridden by a layer they cannot
|
|
see. The additions are `static_profile` alongside the Home Assistant price
|
|
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
|
and a `static_profile` that does not tile the day, which would leave hours
|
|
with no price at all.
|
|
"""
|
|
problems: list[str] = []
|
|
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
|
|
|
if pid != "off" and pid not in PROVIDERS:
|
|
problems.append(
|
|
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
|
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
|
|
"Refused rather than defaulted, per the §14 warning box - a "
|
|
"silent fall back to 'off' would look exactly like a working "
|
|
"provider that happens never to find a cheap hour.")
|
|
return problems # nothing below can be judged against an unknown id
|
|
|
|
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
|
# price_now_entity/price_avg_entity are the original delegation of pricing
|
|
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
|
|
# the maintenance cheap-window charge floor. They are KEPT - retiring them
|
|
# would take a live install's only price signal away - but they may not run
|
|
# alongside another price source, because main.py reads exactly one and the
|
|
# loser is silent. That is (a), and it is the "silently double up" the PM
|
|
# named on 2026-08-30.
|
|
#
|
|
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
|
|
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
|
|
# "would decide nothing", which was true only because nothing consumed the
|
|
# series; test_price.py section 12 carried a tripwire that went red the
|
|
# moment a consumer landed, with instructions to fix the wording here in the
|
|
# same commit. It landed, it went red, and this is that fix. What is true
|
|
# NOW, stated exactly and no wider:
|
|
#
|
|
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
|
|
# provider series into a TariffPlan, a cheap-window flag and an
|
|
# arbitrage verdict, so the seam is no longer inert.
|
|
# * main.py reads the STATIC side of that module every cycle
|
|
# (static_status()), which makes static_profile a real writer of `cheap`.
|
|
# * main.py does NOT yet call resolve_tariff() on the control tick, and
|
|
# that is deliberate: a provider fetch is network I/O and the tick is
|
|
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
|
|
# no energy decision on its own; a configured STATIC PROFILE does.
|
|
#
|
|
# Hence two refusals below rather than one, and neither claims more than
|
|
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
|
|
# first message's middle clause is what changes - not the rule.
|
|
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
|
if str(opts.get(k, "") or "").strip()]
|
|
if pid != "off" and ha_price:
|
|
problems.append(
|
|
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
|
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
|
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
|
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
|
"reads. The provider series IS consumed now - app/static_tariff.py "
|
|
"builds a tariff plan and an arbitrage verdict from it - but it is "
|
|
"not what main.py acts on, so the supplier you configured would "
|
|
"shape no energy decision while the status page named it. Clear "
|
|
"price_now_entity and price_avg_entity to use the "
|
|
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
|
"Home Assistant sensors.")
|
|
|
|
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
|
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
|
# from the Home Assistant pair when it is set and from the static profile
|
|
# otherwise - one flag, first writer wins, and the operator who filled in a
|
|
# day/night tariff has no way to see that it lost.
|
|
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
|
problems.append(
|
|
f"static_profile is set AND {' and '.join(ha_price)} "
|
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
|
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
|
"ENTITIES WIN: main.py reads them first and never looks at the "
|
|
"static profile, so the day/night tariff you typed would decide "
|
|
"nothing and nothing would say so. Clear price_now_entity and "
|
|
"price_avg_entity to use static_profile, or clear static_profile "
|
|
"to keep the Home Assistant sensors.")
|
|
|
|
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
|
# that does not tile the day has hours with no price, and the only ways to
|
|
# serve those are to invent a number or to fail at the moment the clock
|
|
# reaches them. §14's warning box - refuse to start on a mismatch rather
|
|
# than clamp quietly - is the same instruction one level down.
|
|
raw_profile = opts.get("static_profile", "")
|
|
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
|
|
try:
|
|
profile = parse_static_profile(raw_profile)
|
|
except ProfileInvalid as e:
|
|
profile = None
|
|
problems.append(f"static_profile cannot be used: {e}")
|
|
if profile is not None and profile.product is not None \
|
|
and len(set(profile.prices)) == 1:
|
|
_LOG.info(
|
|
"static_profile carries one distinct price (%.4f EUR/kWh "
|
|
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
|
"off by arithmetic - a flat series has no spread - and the "
|
|
"add-on runs self-consumption only, which is §9.2's handling "
|
|
"for this product. Peak shaving, reserve management and the "
|
|
"maintenance cycle are unaffected.", profile.prices[0])
|
|
|
|
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
|
# site with no price data falls back to the static profile; a dynamic site
|
|
# with none configured has nothing to fall back to when its feed dies. That
|
|
# is a real gap worth naming - but refusing to start over it would break
|
|
# every install TARIFF-02 shipped, none of which could have had one.
|
|
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
|
_LOG.warning(
|
|
"price_provider is %r but static_profile is empty. §11.4's "
|
|
"degradation row is 'no price data -> fall back to the static "
|
|
"profile'; with none configured, a feed outage leaves this site "
|
|
"with no tariff at all rather than with a coarse one. Not refused, "
|
|
"but filling it in is what makes that row work.", pid)
|
|
|
|
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
|
problems.append(
|
|
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
|
"the offline fallback has no file to read. Every fetch would "
|
|
"return no-price-data with a filesystem error as its reason - "
|
|
"which is §11.4 behaviour arrived at by accident rather than by "
|
|
"configuration. Point it at the CSV, or set price_provider to "
|
|
"'off', which says the same thing on purpose.")
|
|
|
|
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)
|