checks / unit (pull_request) Successful in 2m33s
Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, manual_csv - all emitting exactly
{"prices": [{"start", "eur_per_kwh", "provisional"}]}, tz-aware at the
Brussels offset, EUR/kWh, all-in. Nothing consumes it yet; the seam
lands first, per the PM's ordering.
It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.
Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:
Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.
The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.
"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. A fetch returns PriceRead.series(...)
or PriceRead.no_data(reason), and only the first carries fields. An
empty series is not constructible; no_data has no .points, no .at(), and
.get("points", []) raises rather than returning the default. A day of
free electricity and a day with no prices are different instructions -
arbitrage is pointless under one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.
Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.
validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.
CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.
test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
494 lines
28 KiB
Python
494 lines
28 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.
|
|
|
|
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
|
|
from .price import PROVIDERS
|
|
|
|
_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))
|
|
|
|
# --- 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.
|
|
|
|
Three refusals, all case (a) from this module's docstring: a source the
|
|
operator SET being overridden by a layer they cannot see.
|
|
"""
|
|
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 existing delegation of pricing
|
|
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read only
|
|
# by the maintenance cheap-window charge floor. They are KEPT (TARIFF-03 and
|
|
# OPT-04 are what replace them, and neither has landed), but they may not
|
|
# run alongside a provider. With both configured the provider series drives
|
|
# nothing at all while the two entities quietly keep deciding, and the
|
|
# operator's evidence that they picked a supplier is a status line saying
|
|
# they picked a supplier. That is (a), and it is the "silently double up"
|
|
# the PM named on 2026-08-30.
|
|
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, and nothing yet consumes the provider series, so the "
|
|
"supplier you configured would decide nothing while the status "
|
|
"page named it. Clear price_now_entity and price_avg_entity to use "
|
|
f"the {pid!r} plugin, or set price_provider to 'off' to keep the "
|
|
"Home Assistant sensors.")
|
|
|
|
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)
|