checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.
* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
and `_verify_moves()`, not restated - on its own marker, because unlike
DEV-02's it runs only where the roster has more than one pack. The flat keys
are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
the packs' own stored phases, never persisted, so a restart mid-drain
re-derives it. Several packs due in the same hour: the slot goes to the one
that has gone longest without a completed cycle; roster order breaks a
numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
stamp `last_start_attempt` and does not move the phase, so neither the 12 h
drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
the roster now, as its own comment said it must once `maint_unit` stopped
being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
migration does not run, the four cycle events are the shipped strings, the
banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
claim, so below the pin the allocator refuses the split and the site parks at
0 W. Pre-existing at 2d025ee. Pinned as a running check.
Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1179 lines
69 KiB
Python
1179 lines
69 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` - or SAFETY-30's `unit_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.
|
|
|
|
9. An `inverters:` roster that cannot mean what it says. [(a) and (b)]
|
|
CFG-04's five cases are enumerated on `_roster_problems()`. Three of them
|
|
are (b) - an entry with no `setpoint_entity`, or no rating, is a unit that
|
|
takes a share of the site target every cycle and delivers none of it, on a
|
|
status page that reports a site running at full strength. Two are (a): two
|
|
entries sharing a name share one Store namespace, so one unit's SAFETY-02
|
|
latch IS the other's and an operator clearing one clears both; and a flat
|
|
`setpoint_entity` left set beside a declared roster is an actuator the
|
|
configuration names and nothing writes.
|
|
|
|
⚠️ RULES 5 AND 6 CHANGED SHAPE FOR THIS, and that is the part worth
|
|
reading. Both compared a threshold against the flat `max_w`, which was
|
|
right while there was exactly one inverter and is a PERMISSIVE READER the
|
|
moment there are two: `max_w: 5000` left over from a single-inverter
|
|
install, beside a roster of two 2000 W units, passed rule 5 at
|
|
`sign_disagree_w: 3000` while the watchdog could fire on NEITHER unit -
|
|
rule 5's own failure mode, reached through rule 5. They now read
|
|
`unit_caps()`, which is the roster's caps and, at one inverter, is
|
|
`[max_w]` exactly.
|
|
|
|
What rule 9 deliberately does NOT do is grade a rating against a device.
|
|
`dev_min`/`dev_max` here are one device's range and there are now N of
|
|
them, none of which has answered at startup. `validate_unit_ranges()` is
|
|
the per-unit form and main.py runs it from `run_control()`, once every
|
|
unit's own ESPHome has replied.
|
|
|
|
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 .inverter import roster_entries
|
|
from .maintenance import MAINT_OPTION_DEFAULTS
|
|
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
|
|
from .watchdog import SIGN_OPTION_DEFAULTS
|
|
|
|
_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 OPTION KEYS UNDER WHICH A WRITER CAN BE DECLARED - AN ENUMERATION, AND
|
|
# SAFETY-28 DEMOTED IT TO ONE. This tuple used to BE the guarantee. The comment
|
|
# that stood here said specs §1.5 makes "any configuration in which two writers
|
|
# can reach one actuator" a non-goal, and that the non-goal was ENFORCED by
|
|
# construction: exactly one such key, so a second writer is not expressible in
|
|
# the options schema at all.
|
|
#
|
|
# That was true, and it graded THE SHAPE OF THE OPTIONS SCHEMA rather than the
|
|
# writes. Add CFG-04's per-inverter config - a list of inverters, each with its
|
|
# own `setpoint_entity` - and there are two real writers with `len()` of this
|
|
# tuple still equal to 1. The check stays green while the property it protects
|
|
# is gone, which is this repository's named failure mode: a check that observes
|
|
# something other than what it claims to observe.
|
|
#
|
|
# So the guarantee moved to `actuator_writers()` below, which counts WRITERS PER
|
|
# ACTUATOR rather than keys, and finds them wherever in the options tree they
|
|
# are declared. This tuple survives only because a census has to know which key
|
|
# NAMES an actuator; it is not a bound on how many writers there can be, and
|
|
# nothing should ever assert on its length again.
|
|
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
|
|
|
|
|
|
def _collect_writers(node, path: str, out: list) -> None:
|
|
"""Walk the options tree, collecting `(where, entity)` for every writer.
|
|
|
|
⚠️ STRUCTURAL, NOT NAME-BASED ON THE CONTAINER, and that is the point. A
|
|
census that looked for a key called `inverters` would be guessing the name
|
|
CFG-04 has not chosen yet, and a guess that is wrong goes GREEN while two
|
|
writers exist - the same defect one level along. This walks whatever shape
|
|
the options happen to have and reports every `setpoint_entity` in it,
|
|
however deeply nested and under whatever container.
|
|
"""
|
|
if isinstance(node, dict):
|
|
for k, v in node.items():
|
|
here = f"{path}.{k}" if path else str(k)
|
|
if k in ACTUATOR_WRITE_KEYS:
|
|
entity = str(v or "").strip()
|
|
if entity:
|
|
out.append((here, entity))
|
|
continue
|
|
_collect_writers(v, here, out)
|
|
elif isinstance(node, (list, tuple)):
|
|
for i, v in enumerate(node):
|
|
_collect_writers(v, f"{path}[{i}]", out)
|
|
|
|
|
|
def actuator_writers(opts: dict) -> dict:
|
|
"""actuator -> the configured writers that can reach it. INSTANCE-AWARE.
|
|
|
|
The replacement for `len(ACTUATOR_WRITE_KEYS) == 1`. It answers HOW MANY
|
|
THINGS CAN WRITE TO EACH ACTUATOR, which is the property §1.5 actually
|
|
names, and it keeps answering it when the schema grows a per-inverter list:
|
|
two inverters with two entities are two writers on two actuators and fine,
|
|
two declarations naming ONE entity are the non-goal and are refused.
|
|
|
|
The key is the entity id, lowercased - Home Assistant entity ids are
|
|
lowercase by construction, and folding case here means a config cannot
|
|
smuggle a second writer past the census on `Number.X` versus `number.x`.
|
|
|
|
⚠️ SAY WHAT THIS STILL DOES NOT OBSERVE, because the argument that demoted
|
|
the tuple applies to its replacement too. This is a census of CONFIGURED
|
|
writers. A second write path added in CODE - a `hass.set_number()` call next
|
|
to some new strategy - declares nothing and is invisible here, exactly as it
|
|
was invisible to the tuple. That hole is not closed at startup and cannot
|
|
be: it is closed at runtime, by `Controller.write_refusal()`, which refuses
|
|
any value the single allocator did not place on this tick and does not care
|
|
what the options look like. The two checks are deliberately different
|
|
kinds - one grades the config, one grades the writes - and only the second
|
|
one survives a schema this file has not seen yet.
|
|
"""
|
|
found: list = []
|
|
_collect_writers(opts, "", found)
|
|
census: dict = {}
|
|
for where, entity in found:
|
|
census.setdefault(entity.lower(), []).append(where)
|
|
return census
|
|
|
|
|
|
def unit_caps(opts: dict) -> list[float]:
|
|
"""Each configured inverter's supervised cap, in roster order.
|
|
|
|
⚠️ WHY THIS EXISTS AT ALL, AND IT IS THE PERMISSIVE-READER PROBLEM IN THIS
|
|
FILE. Rules 5 and 6 both compare a threshold against "max_w", and both of
|
|
them are really comparing it against WHAT ONE UNIT MAY BE COMMANDED - the
|
|
arbiter files `supervised` per inverter, from `inv.max_w`, and there is no
|
|
such thing as a site rating on the wire. At one inverter those are the same
|
|
number and reading the flat option was right. At two they are not: a config
|
|
with `max_w: 5000` left over from a single-inverter install and a roster of
|
|
two 2000 W units would pass rule 5 at `sign_disagree_w: 3000` while the
|
|
watchdog could not fire on EITHER unit - the disarmed-guard-reporting-armed
|
|
state those rules exist to refuse, reached through the rule itself.
|
|
|
|
So the caps come from the roster the add-on will actually build
|
|
(`inverter.roster_entries()`, not a second reading of the option), and fall
|
|
back to the flat `max_w` only where that roster is the flat one too.
|
|
"""
|
|
return [cap for _, cap in unit_caps_indexed(opts)]
|
|
|
|
|
|
def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
|
"""`unit_caps()`, but each cap carries the ROSTER INDEX it came from.
|
|
|
|
⚠️ WHY THE INDEX HAS TO TRAVEL WITH THE NUMBER. This function SKIPS entries
|
|
with no usable `max_w`, so position i in the caps list is not position i in
|
|
the roster the moment one entry is malformed. Rules 5 and 6 used to feed a
|
|
caps-list index straight into `_unit_label()`, which indexes `entries` - so
|
|
on a roster whose first entry has a junk `max_w`, the refusal named the
|
|
WRONG INVERTER. An operator told to lower a number on `inverter-a` when the
|
|
number is on `inverter-b` is being sent to the wrong device by the very
|
|
message that exists to stop that. Rule 9(d) already refuses such a config so
|
|
the two lists cannot desynchronise in shipped use - but "unreachable" is a
|
|
claim about today's rule ordering, not a property of this function, and
|
|
`validate_startup_config()` is also `sim/inject.py`'s entry point.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
cap = _number(opts, "max_w", 2000.0)
|
|
return [] if cap is None else [(0, cap)]
|
|
caps: list[tuple[int, float]] = []
|
|
for index, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
cap = _number(entry, "max_w", None)
|
|
if cap is not None:
|
|
caps.append((index, cap))
|
|
return caps
|
|
|
|
|
|
def _unit_label(opts: dict, index: int) -> str:
|
|
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
|
|
|
The messages below have to read identically on the install that upgrades
|
|
into this version - it is the same configuration, refused for the same
|
|
reason - and have to NAME THE UNIT the moment there is more than one, or an
|
|
operator with three inverters is told a number is wrong and not which one.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if len(entries) <= 1:
|
|
return ""
|
|
entry = entries[index] if index < len(entries) else {}
|
|
name = ""
|
|
if isinstance(entry, dict):
|
|
name = str(entry.get("name") or "").strip()
|
|
return f" ({name or f'inverters[{index}]'})"
|
|
|
|
|
|
def _roster_problems(opts: dict) -> list[str]:
|
|
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
|
what it says.
|
|
|
|
⚠️ THE SUPERVISOR ALREADY ENFORCES MOST OF THIS AND THAT IS NOT A REASON TO
|
|
DROP IT. `config.yaml`'s schema makes `name`, `setpoint_entity` and `max_w`
|
|
required per entry, and Supervisor 2026.08.0 refuses a blob that omits one
|
|
by name - measured, not assumed. But `validate_startup_config()` is also
|
|
`sim/inject.py`'s entry point and every check's, and those reach it with a
|
|
dict the Supervisor never saw; the same argument `price_provider` makes two
|
|
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
|
callers it does not.
|
|
|
|
Five things are refused, and each is a config that would otherwise RUN:
|
|
|
|
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
|
can name it; without the rule the roster would be one inverter built
|
|
out of a string's `.get`, i.e. an immediate AttributeError.
|
|
b. An entry with no usable `name`. Store keys are namespaced BY NAME
|
|
(`store.unit_key`), so two nameless entries would share one namespace
|
|
and one unit's SAFETY-02 latch would silently be the other's.
|
|
c. Two entries with the SAME name - (b) reached the long way round.
|
|
d. An entry with no `setpoint_entity`, or no usable `max_w`. The first is
|
|
a unit this controller cannot command; the second is a unit whose
|
|
supervised claim would be `+-0 W`, which is a unit pinned at zero by a
|
|
missing key rather than by a decision.
|
|
e. A flat `setpoint_entity` alongside a declared roster. The flat keys are
|
|
the SINGLE-inverter shape; leaving one set while a roster is declared
|
|
means one of two things and neither is safe. If it names an entity an
|
|
entry also names, SAFETY-28's census below already refuses it as two
|
|
writers on one actuator. If it names a DIFFERENT one, nothing writes
|
|
it, the site runs quietly smaller than the configuration reads, and the
|
|
one place it would show up is a status page nobody compares against the
|
|
config. Refused rather than ignored, for §1.5's reason and for this
|
|
card's: a config that silently becomes a different config is worse than
|
|
one that refuses to load.
|
|
"""
|
|
problems: list[str] = []
|
|
raw = opts.get("inverters")
|
|
if raw is None or raw == "" or raw == []:
|
|
return problems
|
|
if not isinstance(raw, (list, tuple)):
|
|
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
|
|
"LIST of inverter objects - one per inverter, each naming its "
|
|
"own name, setpoint_entity and max_w. A single inverter needs "
|
|
"no list at all: leave `inverters` empty and the flat "
|
|
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
|
|
|
|
entries = roster_entries(opts)
|
|
seen: dict = {}
|
|
for i, entry in enumerate(entries):
|
|
where = f"inverters[{i}]"
|
|
if not isinstance(entry, dict):
|
|
problems.append(
|
|
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
|
|
"must be an object with at least name, setpoint_entity and "
|
|
"max_w.")
|
|
continue
|
|
name = str(entry.get("name") or "").strip()
|
|
if not name:
|
|
problems.append(
|
|
f"{where} has no name. Every inverter needs one: the Store "
|
|
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
|
|
"write bookkeeping under it, so two unnamed units would share "
|
|
"one namespace and a trip caused by one would latch the other. "
|
|
"Any short identifier works - inverter-a, inverter-b, garage.")
|
|
elif name in seen:
|
|
problems.append(
|
|
f"{where} and {seen[name]} are both called {name!r}. The Store "
|
|
"keys every per-unit latch under the unit's name, so two units "
|
|
"with one name share one SAFETY-02 latch and one heartbeat "
|
|
"deadline - a trip caused by either would stop both, and an "
|
|
"operator clearing it would clear both. Give them different "
|
|
"names.")
|
|
else:
|
|
seen[name] = where
|
|
who = f"{where} ({name})" if name else where
|
|
if not str(entry.get("setpoint_entity") or "").strip():
|
|
problems.append(
|
|
f"{who} has no setpoint_entity, so this add-on cannot command "
|
|
"it. It would sit in the roster, take a share of the site "
|
|
"target every cycle and deliver none of it, while the status "
|
|
"page showed a site running at full strength. Give it the "
|
|
"ESPHome number entity that carries ITS setpoint, or remove "
|
|
"the entry.")
|
|
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
|
problems.append(
|
|
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
|
|
"the supervised cap on what may be written to THIS inverter, it "
|
|
"is not shared with any other unit and it is deliberately not "
|
|
"inherited from the flat max_w - a rating borrowed from a "
|
|
"sibling is exactly what DEV-02 removed. State this unit's own "
|
|
"rating in watts.")
|
|
|
|
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
|
if entries and flat_sp:
|
|
problems.append(
|
|
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
|
|
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
|
|
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
|
|
"read at all once a roster exists, so this config declares an "
|
|
"actuator that nothing will ever write - a site running quietly "
|
|
"smaller than its own configuration. Move that inverter into the "
|
|
"list as its own entry and clear setpoint_entity.")
|
|
return problems
|
|
|
|
|
|
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
|
"""Rule 2, per inverter, against each unit's OWN reported device range.
|
|
|
|
⚠️ THE READER THIS REPLACES FAILED PERMISSIVE, WHICH IS THE DIRECTION THAT
|
|
HIDES A DEFECT. `run_control()` re-ran rule 2 behind `if
|
|
self.o.get("setpoint_entity")` - the FLAT key - because the flat options
|
|
were the only per-device options there were. Declare a roster and that key
|
|
is blank by rule 9(e), so the whole re-check was skipped and NO inverter's
|
|
`max_w` was ever graded against the device it is written to. That is rule
|
|
2's own failure mode restored by a config shape: the operator's stated
|
|
ceiling is not the ceiling and nothing says which won.
|
|
|
|
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
|
|
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
|
guessed at, for the reason in this module's docstring: ESPHome is often not
|
|
up when the add-on boots and (None, None) means "not read", not "zero".
|
|
|
|
`skip_entity` is the flat `setpoint_entity` when the caller has already
|
|
graded it through `validate_startup_config(opts, dev_min, dev_max)`. At a
|
|
single-inverter config that is the one unit, and grading it twice would
|
|
report the same conflict twice under two wordings.
|
|
"""
|
|
problems: list[str] = []
|
|
skip = str(skip_entity or "").strip().lower()
|
|
for unit in units:
|
|
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
|
|
if not entity or entity.lower() == skip:
|
|
continue
|
|
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
|
|
dev_min = getattr(unit, "dev_min", None)
|
|
dev_max = getattr(unit, "dev_max", None)
|
|
name = getattr(unit, "name", entity)
|
|
if dev_max is not None and cap > float(dev_max):
|
|
problems.append(
|
|
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
|
|
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
|
|
f"configured for this inverter would silently never happen. "
|
|
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
|
|
elif dev_min is not None and -cap < float(dev_min):
|
|
problems.append(
|
|
f"{name}: max_w is {cap:.0f} W, so this controller may command "
|
|
f"{-cap:.0f} W, but {entity} accepts no less than "
|
|
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
|
|
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
|
|
"less.")
|
|
return problems
|
|
|
|
|
|
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, and AT MOST ONE WRITER PER ACTUATOR -----
|
|
writers = actuator_writers(opts)
|
|
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.")
|
|
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
|
# count of option keys. Read `actuator_writers()` for why the old check went
|
|
# green on the very config this one is here to refuse.
|
|
#
|
|
# ⚠️ AND IT REFUSES A DUPLICATE DECLARATION EVEN WHEN BOTH NAME THE SAME
|
|
# ENTITY, which is the case an author of the per-inverter config will meet
|
|
# first: keep `setpoint_entity` as inverter A's and add A to the list again
|
|
# and there are two config sites aimed at one actuator, each with its own
|
|
# neighbouring options. "They happen to be the same entity, so it is fine"
|
|
# is precisely the reasoning that makes two writers invisible. Declare each
|
|
# actuator exactly once and the refusal does not fire.
|
|
for actuator, who in sorted(writers.items()):
|
|
if len(who) > 1:
|
|
problems.append(
|
|
f"{len(who)} configured writers can reach the one actuator "
|
|
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
|
|
"configuration in which two writers can reach one actuator a "
|
|
"non-goal - two independent setpoints on one inverter is the "
|
|
"arbitration problem this add-on refuses to have, and at "
|
|
"runtime it is a meter-emulation target being written twice a "
|
|
"second by two strategies that cannot see each other. Give "
|
|
"each actuator exactly one writer, or point the second one at "
|
|
"its own entity.")
|
|
|
|
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
|
|
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
|
# roster what one unit may be commanded, so a roster that is nonsense has to
|
|
# have been named as nonsense before they read it - otherwise an entry with
|
|
# no `max_w` silently contributes no cap, `min()` sees a shorter list, and
|
|
# the operator is told about a sign watchdog when the real problem is a
|
|
# missing rating one line up.
|
|
problems.extend(_roster_problems(opts))
|
|
|
|
# --- 2. the configured ceiling against the device's own rating -----------
|
|
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
|
|
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
|
|
# speak for the single-inverter shape. The per-unit form of it is
|
|
# `validate_unit_ranges()`, which main.py runs once every unit's own device
|
|
# has answered; it is not called from here because at startup no device has.
|
|
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".
|
|
#
|
|
# ⚠️ AND IT IS GRADED AGAINST THE SMALLEST UNIT'S CAP, NOT AGAINST THE FLAT
|
|
# `max_w`. There is one SignWatchdog PER INVERTER (DEV-02) and each grades
|
|
# its own unit's standing command, which the arbiter bounds by THAT UNIT's
|
|
# `supervised` claim - so the watchdog that can never fire is the one on the
|
|
# smallest inverter, and a threshold under the site's largest cap says
|
|
# nothing about it. At one inverter `unit_caps()` is `[max_w]` and this is
|
|
# the identical comparison it always was.
|
|
sign_w = _number(opts, "sign_disagree_w", 300.0)
|
|
# ⚠️ (index, cap) PAIRS, NOT BARE CAPS. `unit_caps()` skips entries with no
|
|
# usable `max_w`, so `caps.index(weakest)` was an index into the CAPS list
|
|
# being handed to `_unit_label()`, which indexes the ROSTER - and the message
|
|
# named the wrong inverter as soon as the two lengths differed. The index
|
|
# travels with the number now, so the name is the name of the unit the
|
|
# number came off.
|
|
caps_ix = unit_caps_indexed(opts)
|
|
caps = [cap for _, cap in caps_ix]
|
|
weakest = min(caps) if caps else None
|
|
if sign_w is None:
|
|
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
|
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
|
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
|
problems.append(
|
|
f"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
|
|
f"{weakest:.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 {weakest:.0f} W (300 W is the measured "
|
|
"default), or raise max_w.")
|
|
|
|
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
|
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
|
# unit's own SHARE against that unit's own measured power, and a share is
|
|
# bounded by the same `supervised` claim at +-max_w - so a threshold at or
|
|
# above the ceiling makes the command side unsatisfiable by construction,
|
|
# exactly as above. Written as its own branch rather than folded into the
|
|
# one above because the two produce different sentences and an operator
|
|
# needs to be told WHICH watchdog they have disarmed.
|
|
#
|
|
# ⚠️ REFUSED EVEN AT A ROSTER OF ONE, WHERE THIS CHECK IS NOT ARMED. A
|
|
# config that cannot work is refused when it is WRITTEN, not on the day a
|
|
# second inverter is added - which is the day nobody is watching the startup
|
|
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
|
# prevents is silent.
|
|
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
|
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
|
if unit_sign_w is None:
|
|
problems.append(f"unit_sign_disagree_w "
|
|
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
|
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
|
problems.append(
|
|
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
|
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
|
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
|
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
|
"inverters that is the guard which catches a unit doing the "
|
|
"OPPOSITE of what it was told while its sibling covers for it at "
|
|
"the meter - the case the site-level watchdog is structurally "
|
|
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
|
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} 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, READ RATHER THAN TYPED
|
|
# (CFG-03). 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
|
|
# and this default is dead code. That is precisely why it went stale
|
|
# unnoticed for a whole card: MAINT-04 moved config.yaml to 2000 while
|
|
# main.py and MaintConfig still said 2500, so a dict with the key ABSENT was
|
|
# accepted here at 2000 and then built by main.py at 2500 - the
|
|
# contradiction this rule exists to catch, in the one case it could not see.
|
|
# The KNOWN MISMATCH block that used to stand here, and the tripwire in
|
|
# test_validate.py that pinned it, are both DELETED: the divergence is
|
|
# closed rather than recorded. `maintenance.MAINT_OPTION_DEFAULTS` is now
|
|
# the one in-code mirror of config.yaml for this family - the same move
|
|
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
|
# test_maintenance.py pins it by reading the shipped file.
|
|
#
|
|
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
|
|
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
|
|
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
|
|
# recalibrates - the first in the roster, because the cycle's history lives
|
|
# under un-namespaced Store keys. The shaping claim is filed on THAT
|
|
# actuator and on no other, so the cap that can contradict it is THAT
|
|
# unit's. Taking `min()` here would refuse a perfectly workable site - a
|
|
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
|
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
|
|
# with it."
|
|
#
|
|
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
|
|
# per roster entry, each with its own namespaced history, and EVERY pack is
|
|
# recalibrated on its own schedule - so the sibling "the cycle never
|
|
# touches" does not exist any more and the over-refusal argument has no
|
|
# subject. The shaping claim is filed on whichever pack is charging, so the
|
|
# cap that can contradict it is any cap on the roster, and the one that
|
|
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
|
|
# rule 5's reason: a threshold that is fine against the biggest unit says
|
|
# nothing about the smallest, and the smallest is the one that goes to 0 W.
|
|
#
|
|
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
|
|
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
|
|
# `maintenance_charge_w` 3000 was accepted before this card and was correct
|
|
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
|
|
# its cheap-window charge asks the arbiter for `hi = -3000` against a
|
|
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
|
|
# a maintenance charge that silently never happens on that pack, which is
|
|
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
|
|
# roster of one `min()` over one cap is that cap and the comparison, the
|
|
# message and the boundary are identical.
|
|
charge_w = _number(opts, "maintenance_charge_w",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
|
|
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
|
|
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
|
|
# roster position and `_unit_label()` indexes the roster. The index travels
|
|
# with the number, so the unit the message names is the unit the number came
|
|
# off - which on a roster whose first entry has a junk `max_w` is not the
|
|
# same thing at all.
|
|
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
|
|
else (0, None))
|
|
maint_where = _unit_label(opts, maint_index)
|
|
if charge_w is None:
|
|
problems.append(
|
|
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
|
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
|
problems.append(
|
|
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
|
|
f"{maint_cap:.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"{-maint_cap:.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 {maint_cap:.0f} W or "
|
|
f"less, 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 used to document a LIVE divergence in this repo caused by typing
|
|
# 2000 here and 2500 in main.py; SAFETY-24 refused to add a second instance
|
|
# of it, and CFG-03 has since closed the first. The dict is the single
|
|
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
|
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
|
# for the `maintenance_*` family.
|
|
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.
|
|
# ⚠️ AND THE FALLBACK COMES FROM THE SAME MIRROR AS RULE 6's. CFG-03's PM
|
|
# proofread caught this one: `maintenance_discharge_w` had the IDENTICAL
|
|
# three-way divergence (config.yaml 2000, MaintConfig 2500) and the card
|
|
# named only the charge side. Closing one key and leaving its twin is how a
|
|
# single-source fix gets reproduced as a single-source bug one line down.
|
|
# ⚠️ AND AGAINST THE MAINTENANCE UNIT'S CAP, for rule 6's reason: it is the
|
|
# same one actuator being clamped, so grading the warning against the flat
|
|
# `max_w` would warn about a site number while the clamp is a unit number.
|
|
disch_w = _number(opts, "maintenance_discharge_w",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]))
|
|
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
|
and disch_w > maint_cap):
|
|
_LOG.warning(
|
|
"maintenance_discharge_w %.0f W is above max_w%s %.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, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
|
maint_cap)
|
|
|
|
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)
|