checks / unit (pull_request) Successful in 3m37s
SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
device rating at or below 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.
The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.
THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.
The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.
A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.
AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:
validate_startup_config({... max_w: 5000}, -4700, 4700)
-> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
WINS ...']
A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.
HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.
Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.
Full suite: 15/15 in goodwe-addon-ci.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
721 lines
43 KiB
Python
721 lines
43 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.
|
|
- A device rating that makes SAFETY-18's freeze rail unreachable. SAFETY-20's
|
|
AC 4, decided here rather than left implicit: **NO NEW RULE, and rule 2 is
|
|
the reason.** The configuration the ticket is about is `max_w` above the
|
|
setpoint device's rating, which is rule 2 EXACTLY - already refused, on the
|
|
identical predicate, since CFG-01, and re-run by `run_control()` once
|
|
ESPHome is up and the real rating is known. A second rule phrased as
|
|
"`RAIL_FRAC * max_w` exceeds the device rating" would fire on a strict
|
|
subset of the configurations rule 2 already refuses (`0.95 * max_w > dev_max`
|
|
implies `max_w > dev_max`), so it could never be the rule that stopped a
|
|
start, and its whole visible effect would be a second entry in a refusal
|
|
list that already names the problem.
|
|
|
|
⚠️ AND IT WOULD NOT COVER THE CASE THAT REMAINS, WHICH IS WHY THE STATUS ROW
|
|
IS NOT A CONSOLATION PRIZE. The residue after rule 2 is the state where
|
|
`hass.limits()` answered `(None, None)` - ESPHome not up when the add-on
|
|
booted - so the range checks were SKIPPED rather than guessed at, and the
|
|
rating is never re-read afterwards. There is nothing to refuse at startup in
|
|
that state, by construction: the number a refusal would need does not exist
|
|
yet. That is a runtime coverage question, and the runtime surface -
|
|
`freeze.rail_unreachable()` and the NOT COVERED row it feeds - is where it
|
|
is answered. Compare rule 5, which CAN be settled at startup because both
|
|
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
|
|
|
- `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 .arbitrage import enabled as arbitrage_on
|
|
from .p1 import is_enabled, validate_meter_options
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .price import PRICE_OPTION_DEFAULTS, 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 call resolve_tariff() on the control tick, and that
|
|
# is still deliberate: a provider fetch is network I/O and the tick is
|
|
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
|
|
# instead (Controller.run_tariff_refresh), and the tick reads the plan
|
|
# that task last stored - an attribute read, with nothing to block on.
|
|
# * so a configured PROVIDER now DOES shape an energy decision, but only
|
|
# through app/arbitrage.py and only while `arbitrage_enabled` is on.
|
|
# With it off - the shipped configuration - the series is still fetched,
|
|
# validated and published, and still moves nothing.
|
|
#
|
|
# Hence three refusals below rather than two, and none of them claims more
|
|
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
|
|
# CHANGED, exactly as the note it replaced predicted - the RULE is
|
|
# untouched, because the reason it exists (main.py reads the Home Assistant
|
|
# pair FIRST and the other source is silent) is unchanged.
|
|
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 read_inputs() never looks at the other source once "
|
|
"they are set. The provider series is consumed - "
|
|
"app/static_tariff.py builds a tariff plan and an arbitrage "
|
|
"verdict, and app/arbitrage.py trades on it off a background "
|
|
"refresh - but the `cheap` flag those entities own is a separate "
|
|
"decision they would silently keep. 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)
|
|
|
|
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
|
# rather than logged, because a strategy that is armed in the UI and cannot
|
|
# fire is indistinguishable from one that fires and finds no opportunity -
|
|
# which is the §14 warning box's case exactly. None of these can break an
|
|
# existing install: the option is new and ships `false`.
|
|
# ⚠️ CHECKED WHETHER OR NOT ARBITRAGE IS ARMED. A threshold that is not a
|
|
# non-negative number is a configuration error the moment it is typed, and
|
|
# deferring the complaint until somebody turns the strategy on would report
|
|
# it at the worst possible moment - the one where energy starts moving.
|
|
thresh = _number(opts, "cycle_cost_threshold_eur",
|
|
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
|
if thresh is None or thresh < 0:
|
|
problems.append(
|
|
f"cycle_cost_threshold_eur is "
|
|
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
|
|
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
|
|
f"(§10.1). A negative threshold is one that every spread clears, "
|
|
f"including a spread of nothing, which is arbitrage with the brake "
|
|
f"removed rather than arbitrage tuned aggressively.")
|
|
|
|
if arbitrage_on(opts):
|
|
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
|
problems.append(
|
|
"arbitrage_enabled is on but price_provider is 'off' and "
|
|
"static_profile is empty, so this site has NO PRICES AT ALL. "
|
|
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
|
|
"there is nothing here to tell cheap from expensive, so the "
|
|
"strategy would refuse on every cycle forever while the "
|
|
"configuration said it was running. Configure a price_provider "
|
|
"or a static_profile, or turn arbitrage_enabled off.")
|
|
if not is_enabled(opts):
|
|
problems.append(
|
|
"arbitrage_enabled is on but meter_source is 'off', so there "
|
|
"is no P1 telegram stream, the capacity-tariff peak guard "
|
|
"never runs, and nothing measures the 15-minute average this "
|
|
"strategy's charging would land in. §9.1 gives peak shaving "
|
|
"priority over arbitrage; an unmeasured peak cannot be shaved, "
|
|
"so arbitrage would be deliberately creating the billed "
|
|
"quantity with the one guard that limits it switched off. "
|
|
"app/arbitrage.py refuses to trade in that state anyway - this "
|
|
"refuses the configuration that asks it to. Set meter_source, "
|
|
"or turn arbitrage_enabled off.")
|
|
floor = _number(opts, "arbitrage_soc_floor",
|
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
|
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
|
if floor is None or ceiling is None:
|
|
problems.append(
|
|
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
|
|
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
|
|
f"must both be numbers - they are percentages of pack charge.")
|
|
elif floor >= ceiling:
|
|
problems.append(
|
|
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
|
|
f"is {ceiling:g} %, so there is no band left between them: "
|
|
f"every state of charge is either at or under the floor (no "
|
|
f"discharge) or at or over the ceiling (no charge), and the "
|
|
f"strategy can never act. The floor is the reserve it may not "
|
|
f"spend and the ceiling is the headroom it may not overfill, "
|
|
f"so the floor must be the SMALLER number.")
|
|
|
|
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)
|