checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.
Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.
MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1266 lines
74 KiB
Python
1266 lines
74 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`, ON ANY UNIT. [(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.
|
|
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
|
|
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
|
|
One number for two packs of different models refuses the wrong config and
|
|
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
|
|
used to start. A missing per-entry cut-out is refused here rather than
|
|
defaulted: a cut-out invented for a pack IS this defect.
|
|
|
|
⚠️ `<=`, 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_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
|
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
|
|
|
`(roster index, the raw value as configured, the parsed float or None)`, one
|
|
triple per unit. The raw value travels too because the refusal quotes what
|
|
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
|
|
string the moment the value came off a roster entry.
|
|
|
|
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
|
|
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
|
|
which indexes the ROSTER, so on a roster whose first entry was malformed the
|
|
refusal named the WRONG INVERTER - an operator sent to the wrong device by
|
|
the very message that exists to stop that. `unit_caps_indexed()` exists
|
|
because of it. This function goes one step further and SKIPS NOTHING: every
|
|
entry produces a triple, including the ones with no usable floor, because a
|
|
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
|
|
therefore no second list for an index to desynchronise from.
|
|
|
|
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
|
|
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
|
|
a sibling's cut-out is graded against a battery it is not. At the flat shape
|
|
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
|
|
mirror exactly as `inverter._flat_bms_floor()` defaults it.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
return [(0, opts.get("bms_soc_floor"),
|
|
_number(opts, "bms_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
|
|
out: list[tuple[int, object, object]] = []
|
|
for index, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
|
|
# runs FIRST. Naming it twice would hand the operator two sentences
|
|
# about one line of YAML.
|
|
continue
|
|
raw = entry.get("bms_soc_floor")
|
|
out.append((index, raw,
|
|
None if raw is None or raw == ""
|
|
else _number(entry, "bms_soc_floor", None)))
|
|
return out
|
|
|
|
|
|
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.
|
|
#
|
|
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
|
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
|
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
|
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
|
# site) or a leftover from a single-inverter install (so it compared against
|
|
# a number no unit has). Both directions were live and both were measured:
|
|
#
|
|
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
|
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
|
# with a per-inverter watchdog reporting `armed` on the status page and
|
|
# incapable of tripping on EITHER unit. That is the identical sentence the
|
|
# branch above exists to refuse, reached through the branch beside it;
|
|
#
|
|
# and a roster of two 5000 W units with no flat key was REFUSED at
|
|
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
|
# wrote, for a threshold both units can reach.
|
|
#
|
|
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
|
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
|
# command against THAT unit's `supervised` bound. So it takes the same
|
|
# `weakest` the branch above takes, from the same list, and names the same
|
|
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
|
# identical comparison it always was - which is what keeps the shipped
|
|
# single-inverter refusal byte-for-byte what it was.
|
|
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
|
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
|
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
|
if caps_ix else "")
|
|
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 weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
|
problems.append(
|
|
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
|
f" is {weakest:.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 {weakest:.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.
|
|
#
|
|
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
|
|
# `bms_soc_floor` for two packs compares the EMS floor against whichever
|
|
# battery happened to be written into the flat key: a site with cut-outs of
|
|
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
|
|
# command a forced discharge that the second pack's BMS - not the operator's
|
|
# floor - is the thing that stops. That is the disarmed-backstop state this
|
|
# rule exists to refuse, reached through the rule itself. So the loop is over
|
|
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
|
|
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
|
|
# this that named the wrong inverter.
|
|
soc_floor = _number(opts, "peak_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
|
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
|
|
where = _unit_label(opts, _ix)
|
|
if soc_floor is None or bms_floor is None:
|
|
problems.append(
|
|
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
|
f"(got {opts.get('peak_soc_floor')!r} / {_raw!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{where}) 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 "
|
|
f"bms_soc_floor{where} 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)
|