A new install names no inverter. When that is the startup gate's only refusal, the add-on now serves the setup wizard in setup-only mode instead of exiting 78. It builds no controller, MQTT client or meter task, its HomeAssistant is read-only (every service call refuses before sending), Controller refuses a read-only HomeAssistant, and /action answers 409. Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md. Wizard fixes from the UX-13 P4 test: - F1: /config/validate converts what it can, so a conversion error blocks its own step and the live checks still run; save 422 carries steps. - F2: saves send the model the page opened (base); only keys this page changed are written onto the blob stored now. - A meter entity missing from Home Assistant is red on the last step. - Step 1 says the reading went down after a skip that followed a fall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2053 lines
112 KiB
Python
2053 lines
112 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)]
|
|
Four cases, all in `_price_problems()`: an unknown `price_provider` id
|
|
(refused rather than defaulted to 'off', per the §14 warning box); a
|
|
RETIRED id - one this add-on really did ship and has been accepting ever
|
|
since, whose plugin never once fetched a price because its endpoint does
|
|
not exist (TARIFF-07), which is case (a) in its purest form: §11.4's
|
|
silent fall back to the static profile IS the layer the operator cannot
|
|
see, and it has been overriding their chosen source since the day they
|
|
set it;
|
|
`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.
|
|
|
|
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
|
|
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
|
|
than a threshold. `slew_w` bounds how far the site command may move in one
|
|
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
|
|
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
|
|
the plant's own measured settling time to traverse its whole output range.
|
|
Above it the controller commands its entire range before it can observe
|
|
its answer to the first step, and the stated limit - "the command moves at
|
|
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
|
|
untrue: it jumps ahead by construction.
|
|
|
|
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
|
|
rather than a warning. Nothing goes inert. What happens is that a bound
|
|
the operator set means something other than what it says, and the layer
|
|
that overrules it is the PLANT - which has no status row, publishes
|
|
nothing, and cannot be read off any page in this add-on. Measured on the
|
|
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
|
|
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
|
|
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
|
|
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
|
|
morning. Nothing in the add-on reported anything wrong, because nothing
|
|
was: the loop followed its command exactly, and the command was wrong.
|
|
|
|
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
|
|
WEAKEST UNIT, and this is the one rule in this file for which that is the
|
|
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
|
|
shaping claim, one pack - so they take `min()` over `unit_caps()`.
|
|
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
|
|
and `compute()` produces the SITE target, which the allocator then splits.
|
|
So the range this bound is about is the sum, `Controller.site_max_w`, and
|
|
`site_rail_w()` below is that same number computed from options.
|
|
|
|
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
|
|
AGAINST THIS. That note says there is deliberately no rule pairing
|
|
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
|
|
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
|
|
is handed options rather than a roster. A rule written against max_w would
|
|
refuse a perfectly reachable two-inverter configuration." That objection is
|
|
to grading a site quantity against the FLAT key. It is answered rather than
|
|
ignored: `unit_caps()` already reads the roster the add-on will build, so
|
|
the roster sum IS available here, and this rule takes it.
|
|
|
|
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
|
|
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
|
|
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
|
|
passes, while at 5000 W the same 1000 W is the configuration measured
|
|
above. That is what the constraint says and it is correct - the bound is on
|
|
traverse TIME, and a bigger rail at the same step size takes longer to
|
|
cross. What this rule therefore protects against is the constant being left
|
|
behind when the rail moves EITHER way. It is NOT a bound on how much power
|
|
the site may command, and it does NOT bound the over-absorption: see the
|
|
note below on `gain`, which is the term that owns that and which SAFETY-39
|
|
measured and did not change.
|
|
|
|
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.
|
|
|
|
- `gain` at any value the schema accepts, INCLUDING against a large rail.
|
|
SAFETY-39 measured this and deliberately left it alone, which is worth more
|
|
than the rule would have been. On admin/goodwe's rig, over a family of 51
|
|
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
|
|
over-absorption - peak battery power beyond what the site actually needed -
|
|
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
|
|
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
|
|
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
|
|
reason is structural and is visible in control.py: `slew_w` bounds the
|
|
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
|
|
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
|
|
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
|
|
ticks whatever `slew_w` is.
|
|
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
|
|
the limit by then" - is now literally true, and `gain` is the constant that
|
|
needs re-deriving next. It is NOT refused and NOT changed here: it is a
|
|
live control-law constant on a running house, changing it in the same pull
|
|
request as `slew_w` would make the next measurement uninterpretable, and a
|
|
validator rule pairing `gain` with a rail would be inventing a relationship
|
|
nobody has measured. Named here so it is a known open item rather than an
|
|
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
|
|
|
|
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
|
|
than the plant is always safe and is sometimes necessary - the live install
|
|
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
|
|
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
|
|
Refusing "too slow" would refuse the mitigation that is holding.
|
|
|
|
- `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 typing import NamedTuple
|
|
|
|
from . import messages, pv
|
|
from .arbitrage import enabled as arbitrage_on
|
|
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
|
from .custom_formula import (
|
|
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
|
|
parse_export_formula, parse_price_formula, stale_card)
|
|
from .inverter import roster_entries
|
|
from .maintenance import MAINT_OPTION_DEFAULTS
|
|
from .p1 import (
|
|
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .price import (
|
|
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
|
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
|
from .shadow import problems as shadow_problems
|
|
from .static_tariff import (
|
|
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
|
|
parse_taxes_profile)
|
|
from .watchdog import SIGN_OPTION_DEFAULTS
|
|
|
|
_LOG = logging.getLogger("goodwe.config")
|
|
|
|
|
|
class _WarnOnce(logging.Filter):
|
|
"""UX-10: a settings warning is said once per start. The validator runs
|
|
twice at start (before the first cycle and again once the inverter's range
|
|
is known), and a warning about options that have not changed is the same
|
|
news both times. Refusals are ERROR and pass untouched."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.seen = set()
|
|
|
|
def filter(self, record):
|
|
if record.levelno != logging.WARNING:
|
|
return True
|
|
text = record.getMessage()
|
|
if text in self.seen:
|
|
return False
|
|
self.seen.add(text)
|
|
return True
|
|
|
|
|
|
WARN_ONCE = _WarnOnce()
|
|
_LOG.addFilter(WARN_ONCE)
|
|
|
|
|
|
class Note(NamedTuple):
|
|
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
|
add-on starts. `keys` are the options it is about, so the page can show it
|
|
beside them."""
|
|
tier: str # "warn" or "info"; the wizard's checks
|
|
# also "ok" and "bad" (setup_checks)
|
|
keys: tuple
|
|
text: str
|
|
|
|
|
|
def _advise(notes, tier: str, keys, text: str) -> None:
|
|
"""Add a Note when the caller collects them (the settings page)."""
|
|
if notes is not None:
|
|
notes.append(Note(tier, tuple(keys), text))
|
|
|
|
|
|
# ⚠️ 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 site_rail_w(opts: dict) -> float | None:
|
|
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
|
|
|
|
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
|
|
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
|
|
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
|
|
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
|
|
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
|
|
the SITE target, which the allocator then divides. So the output range
|
|
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
|
|
number read out of options rather than off a built roster.
|
|
|
|
At the shipped roster of one this is `unit_caps()`'s single entry, which is
|
|
`max_w` exactly - byte for byte the number the single-inverter shape has
|
|
always meant. None when no entry carries a usable cap, which rule 9 has
|
|
already refused by the time rule 10 runs.
|
|
"""
|
|
caps = unit_caps(opts)
|
|
return float(sum(caps)) if caps else None
|
|
|
|
|
|
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 S(key: str, where: str = "") -> str:
|
|
"""A setting as a refusal names it; `where` is `_unit_label()`'s " (name)"."""
|
|
return messages.setting(key, where.strip().strip("()"))
|
|
|
|
|
|
def _labels(keys, joiner: str = "and") -> str:
|
|
"""Several settings, each with its key: the config panel files a refusal
|
|
under every key it names (`configui.problems_by_field()`)."""
|
|
return f" {joiner} ".join(S(k) for k in keys)
|
|
|
|
|
|
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.
|
|
|
|
Seven 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.
|
|
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
|
positive number of kWh. It is read to weight the SPLIT by energy
|
|
headroom; a zero, a negative or a word is a pack the split would
|
|
quietly stop preferring while the operator can see a value in the box.
|
|
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
|
weighting is a COMPARISON between packs, so a pack measured in kWh
|
|
against a sibling weighing a dimensionless 1.0 is not one -
|
|
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
|
case, which means every key the operator filled in does nothing at all
|
|
and nothing says so. All or none, and this is clause (e)'s argument
|
|
restated: 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"{S('inverters')} is {type(raw).__name__} {raw!r}, not a list. "
|
|
"Add one entry per inverter, or leave it empty for a single "
|
|
"inverter."]
|
|
|
|
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} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
|
|
"a name, a setpoint entity and an inverter rating.")
|
|
continue
|
|
name = str(entry.get("name") or "").strip()
|
|
if not name:
|
|
problems.append(
|
|
f"{where} in the “Inverter list” has no name. Give every "
|
|
"inverter a short name, for example inverter-a.")
|
|
elif name in seen:
|
|
problems.append(
|
|
f"{where} and {seen[name]} are both called {name!r}. Give every "
|
|
"inverter its own name.")
|
|
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 {S('setpoint_entity')}, so the add-on cannot "
|
|
"control it. Set its ESPHome number entity, 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 {S('max_w')}: it is "
|
|
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
|
|
"watts.")
|
|
|
|
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
|
stated, blank = [], []
|
|
for i, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
continue # already refused above, by name
|
|
raw = entry.get("capacity_kwh")
|
|
nm = str(entry.get("name") or "").strip()
|
|
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
|
if raw is None or raw == "":
|
|
blank.append(who)
|
|
continue
|
|
try:
|
|
val = float(raw)
|
|
except (TypeError, ValueError):
|
|
val = None
|
|
if val is None or not (val > 0.0):
|
|
problems.append(
|
|
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
|
|
"number. Set it in kWh, or leave it empty on every inverter.")
|
|
else:
|
|
stated.append(who)
|
|
if stated and blank:
|
|
problems.append(
|
|
f"{S('capacity_kwh')} is set on {', '.join(stated)} but empty on "
|
|
f"{', '.join(blank)}. Set it on every inverter, or on none.")
|
|
|
|
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
|
if entries and flat_sp:
|
|
problems.append(
|
|
f"{S('setpoint_entity')} is set to {flat_sp}, but the "
|
|
f"{S('inverters')} is also filled in. Move that inverter into the "
|
|
"list and clear this setting.")
|
|
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}: {S('max_w')} is {cap:.0f} W, but {entity} accepts at "
|
|
f"most {float(dev_max):.0f} W. Lower this inverter's rating to "
|
|
f"{float(dev_max):.0f} W or less.")
|
|
elif dev_min is not None and -cap < float(dev_min):
|
|
problems.append(
|
|
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts no "
|
|
f"less than {float(dev_min):.0f} W when charging. Lower this "
|
|
f"inverter's rating 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 no_power_request_problem() -> str:
|
|
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
|
|
starts when this is the only refusal (app/setuponly.py)."""
|
|
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
|
|
"the inverter. Set it to the ESPHome number entity for the "
|
|
"setpoint.")
|
|
|
|
|
|
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
|
notes: list | None = 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.
|
|
|
|
UX-13: pass a list as `notes` to also get each logged warning as a `Note`
|
|
for the settings page. The refusals returned do not depend on 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(no_power_request_problem())
|
|
# 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)} settings point at the same inverter entity "
|
|
f"{actuator}: {', '.join(who)}. Give each inverter its own "
|
|
"setpoint 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))
|
|
|
|
# --- 11. SAFETY-10's shadow mode, and it is a REFUSAL ---------------------
|
|
# `shadow_mode` decides whether this controller commands the battery at all,
|
|
# so an unreadable value is not a detail to log and carry on past.
|
|
# `shadow.mode()` resolves an unrecognised one to SHADOW - the direction
|
|
# that does not move energy - and an install silently reinterpreted that way
|
|
# commands nothing, indefinitely, while its status page calmly explains
|
|
# that this is intentional. UX-02 made this validator the ONE rule surface
|
|
# both editors read, so the refusal belongs here rather than in a coercion
|
|
# nobody can see. The sentence spells `shadow_mode` so
|
|
# `configui.problems_by_field()` can file it under the field.
|
|
problems.extend(shadow_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"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
|
|
"a number. Set it in watts.")
|
|
# ⚠️ 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"{S('max_w')} is {max_w:.0f} W. Set it above 0 W.")
|
|
elif dev_max is not None and max_w > dev_max:
|
|
problems.append(
|
|
f"{S('max_w')} is {max_w:.0f} W, but "
|
|
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts at "
|
|
f"most {dev_max:.0f} W. Lower the rating to {dev_max:.0f} W or "
|
|
"less.")
|
|
elif dev_min is not None and -max_w < dev_min:
|
|
problems.append(
|
|
f"{S('max_w')} is {max_w:.0f} W, but "
|
|
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts no "
|
|
f"less than {dev_min:.0f} W when charging. Lower the rating to "
|
|
f"{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(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
|
f"both be numbers. They are "
|
|
f"{messages.shown(opts.get('meter_phases'))} and "
|
|
f"{messages.shown(opts.get('meter_max_age_s'))}.")
|
|
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"{S('meter_max_age_s')} is {max_age_s:.0f} s, but the control "
|
|
f"board stops the inverter after {FIRMWARE_INGEST_VETO_S:.0f} s. "
|
|
f"Set it to {FIRMWARE_INGEST_VETO_S:.0f} s or less.")
|
|
|
|
# --- 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"{S('sign_disagree_w')} is "
|
|
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
|
|
"number. Set it back to 300 W.")
|
|
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"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
|
|
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
|
|
f"never trip. Lower it or raise the rating.")
|
|
|
|
# ⚠️ 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"{S('unit_sign_disagree_w')} is "
|
|
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
|
|
"number. Set it back to "
|
|
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
|
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
|
problems.append(
|
|
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
|
|
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
|
|
f"can never trip. Lower it or raise the rating.")
|
|
|
|
# --- 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"{S('maintenance_charge_w')} is "
|
|
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
|
|
"it in watts.")
|
|
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
|
problems.append(
|
|
f"{S('maintenance_charge_w')} is {charge_w:.0f} W, but the "
|
|
f"{S('max_w', maint_where)} is {maint_cap:.0f} W, so the monthly "
|
|
f"recharge cannot run. Lower it to {maint_cap:.0f} W or less.")
|
|
|
|
# --- 10. a slew rate the plant can actually follow -----------------------
|
|
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
|
# threshold, and the whole point of writing it as one is that the number it
|
|
# bounds must not be able to outlive the rail again. `control.py`'s header
|
|
# carries the derivation; the short version is that `slew_w: 1000` was
|
|
# derived against a 1500 W clamp and the live install raised the clamp to
|
|
# 5000 without revisiting it, so the loop traversed its whole output range
|
|
# in 10 s against a plant measured at ~18 s.
|
|
#
|
|
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
|
|
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
|
|
# which satisfies this rule by construction at every rail there is. It is
|
|
# the shipped default, and a rule that refused it would refuse the fix.
|
|
#
|
|
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
|
|
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
|
|
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
|
|
# satisfied, not violated. Refusing it would refuse the value this rule
|
|
# exists to recommend - and `slew_limit_w()` produces exactly that value
|
|
# for every default install, so `>=` would refuse the shipped default
|
|
# through a float comparison. A tolerance is applied for the same reason:
|
|
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
|
|
# a status page rounded to is one watt over a boundary that is not
|
|
# meaningful to one watt.
|
|
slew_w = _number(opts, "slew_w", 0.0)
|
|
rail = site_rail_w(opts)
|
|
if slew_w is None:
|
|
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
|
|
"not a number. Set it to 0 to follow the inverter "
|
|
"rating.")
|
|
elif slew_w < 0:
|
|
problems.append(
|
|
f"{S('slew_w')} is {slew_w:.0f} W and cannot be negative. Set it to "
|
|
"0 to follow the inverter rating.")
|
|
elif slew_w and rail is not None and rail > 0:
|
|
bound = SLEW_RAIL_FRAC * rail
|
|
if slew_w > bound + 1.0:
|
|
problems.append(
|
|
f"{S('slew_w')} is {slew_w:.0f} W, too fast for a {rail:.0f} W "
|
|
f"site. Set it to 0 or to at most {bound:.0f} W.")
|
|
|
|
# --- 7. two price sources on one site ------------------------------------
|
|
problems.extend(_price_problems(opts, notes))
|
|
|
|
# --- 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"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
|
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
|
|
f"{messages.shown(_raw)}.")
|
|
elif soc_floor <= bms_floor:
|
|
problems.append(
|
|
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
|
f"{S('bms_soc_floor', where)} of {bms_floor:.0f} %. Raise the "
|
|
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
|
|
"level.")
|
|
|
|
# --- 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. This is allowed, "
|
|
"but it only adds overshoot, so 0 is recommended.",
|
|
integ_w, max_w)
|
|
_advise(notes, "warn", ("integrator_max_w",),
|
|
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
|
"adds overshoot, so set it to 0.")
|
|
|
|
# ⚠️ 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, so "
|
|
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
|
|
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
|
|
_advise(notes, "warn", ("maintenance_discharge_w",),
|
|
f"{S('maintenance_discharge_w')} is above "
|
|
f"{S('max_w', maint_where)}, so the battery empties at "
|
|
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
|
_LOG.debug(
|
|
"Not refused: the drain phase sets the setpoint, the arbiter "
|
|
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
|
"a 720 min timeout.", maint_cap)
|
|
|
|
return problems
|
|
|
|
|
|
def _price_problems(opts: dict, notes: list | None = None) -> 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.
|
|
|
|
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
|
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
|
|
a `custom_formula` with no formula, a formula that does not parse or does
|
|
not reproduce its own card, and one whose card lands outside the plausible
|
|
band - plus two notices, a formula nothing reads and a stale card), 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.
|
|
|
|
⚠️ THE RETIRED-ID BRANCH IS NOT A FRIENDLIER SPELLING OF THE UNKNOWN-ID
|
|
ONE, AND MUST NOT COLLAPSE INTO IT. They answer different questions. An
|
|
unknown id is a TYPO: the operator meant something else and the fix is to
|
|
spell it right. A retired id is a CHOICE THE ADD-ON ACCEPTED - it was in
|
|
the schema, it was in the dropdown, it resolved to a class, and every
|
|
startup since said nothing. What that operator needs is not "this id is
|
|
wrong" but "this id has never once returned a price, and here is what your
|
|
site has actually been running instead" - because §11.4 made the failure
|
|
silent and NOTHING on their status page has ever distinguished it from a
|
|
working provider that never finds a cheap hour. The retired branch is
|
|
therefore FIRST, and it names the id, the endpoint, the observation, the
|
|
tariff that has really been in force, and the move to make.
|
|
"""
|
|
problems: list[str] = []
|
|
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
|
|
|
if pid in RETIRED_PROVIDERS:
|
|
# ⚠️ BEFORE the unknown-id branch, and the order is load-bearing: a
|
|
# retired id is ALSO not in PROVIDERS, so the generic message would
|
|
# win on a reordering and the operator would be told they mistyped
|
|
# something they chose on purpose.
|
|
# ⚠️ AND THE SECOND SENTENCE CHANGES WITH `static_profile`, because
|
|
# "you have been on the static profile" is FALSE on a site that never
|
|
# set one. §11.4 falls back to the static profile; with no profile to
|
|
# fall back to, the site has had no tariff at all - a worse state, and
|
|
# one this message must not paper over.
|
|
been_running = (
|
|
f"the {S('static_profile')}"
|
|
if str(opts.get("static_profile", "") or "").strip()
|
|
else "no tariff at all")
|
|
problems.append(
|
|
f"{S('price_provider')} is {pid!r}, which was withdrawn and never "
|
|
f"fetched a price. This site ran on {been_running}. Choose "
|
|
"'generic_dayahead', 'manual_csv' or 'off'.")
|
|
return problems # nothing below can be judged against a dead id
|
|
|
|
if pid != "off" and pid not in PROVIDERS:
|
|
problems.append(
|
|
f"{S('price_provider')} is {pid!r}, which does not exist. Choose "
|
|
f"one of: off, {', '.join(sorted(PROVIDERS))}.")
|
|
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"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
|
|
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
|
|
"set the source to 'off'.")
|
|
|
|
# ⚠️ 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"{S('static_profile')} is set, but {_labels(ha_price)} would win "
|
|
f"over it. Clear the fixed tariff or "
|
|
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
|
|
|
|
# ⚠️ 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"“{messages.LABELS['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 has one price (%.4f EUR/kWh all-in), so this "
|
|
"site has a fully fixed price. A flat price has no spread to "
|
|
"trade on, so the add-on uses the battery for self-consumption "
|
|
"only. Peak limiting, the reserve and battery maintenance are "
|
|
"not affected.", profile.prices[0])
|
|
|
|
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
|
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
|
# by `price.assemble()` on the ~30 s planning path; a table that only fails
|
|
# when a fetch happens is a site that stops pricing mid-afternoon, and
|
|
# §11.4 turns that into a quiet fallback that looks exactly like a healthy
|
|
# fixed-tariff day. So the parse happens here, once, where a refusal is a
|
|
# banner naming the key.
|
|
raw_taxes_profile = opts.get(TAXES_PROFILE_OPTION, "")
|
|
taxes_table = None
|
|
if str(raw_taxes_profile or "").strip() \
|
|
or isinstance(raw_taxes_profile, (list, dict)):
|
|
try:
|
|
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
|
except ProfileInvalid as e:
|
|
problems.append(f"“{messages.LABELS[TAXES_PROFILE_OPTION]}” cannot be used. {e}")
|
|
|
|
if taxes_table is not None:
|
|
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
|
# exact case (a) this module exists for, and the same refusal shape as
|
|
# the price-entity/static_profile pair above. The table decides when it
|
|
# is set, so a non-default scalar left beside it is a number the
|
|
# operator typed that changes nothing, on the quantity §11.4 makes
|
|
# hardest to notice. It is compared against the SHIPPED DEFAULTS rather
|
|
# than against presence, because the Supervisor writes every schema key
|
|
# into the options file whether the operator touched it or not - so
|
|
# "the operator did not type this" has to be read off the VALUE.
|
|
#
|
|
# ⚠️ TARIFF-18: "UNTOUCHED" IS A SET, NOT ONE NUMBER, AND THE DEFECT
|
|
# THAT MAKES IT ONE IS AN UPGRADE THAT STOPS A BATTERY CONTROLLER
|
|
# STARTING. The Supervisor sets `App.options = deepmerge(config.yaml
|
|
# defaults, stored options)`, so a saved blob carries every schema key
|
|
# whether the operator typed it or not - the reference install's export
|
|
# is 70 of 71. When TARIFF-18 moved this default 0.06 -> 0.11736, every
|
|
# such blob went from "equal to the shipped default" to "not equal to
|
|
# it" without anybody editing anything, and a site that also carries a
|
|
# taxes table would have met `SystemExit(78)` on the upgrade. The
|
|
# sentence below is about a number the OPERATOR TYPED; a superseded
|
|
# shipped default is not one, so it still reads as untouched. The set
|
|
# is in price.py beside the default it supersedes, because the two only
|
|
# ever move together.
|
|
scalar = _number(opts, "price_taxes_eur_kwh",
|
|
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
|
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
|
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
|
if scalar is not None \
|
|
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
|
problems.append(
|
|
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
|
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
|
|
f"{shipped} from {scalar}, or clear the table.")
|
|
|
|
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
|
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
|
# (ORES `13,27`), and the whole table is copied off one card - so the
|
|
# factor-100 error arrives on EVERY row at once and nothing internal to
|
|
# the table disagrees with itself. The parser's own +-10 EUR/kWh bound
|
|
# catches a 13,27 typed raw, but NOT ORES IMPACT's night band of 5,090,
|
|
# which would be a 5.09 EUR/kWh levy sailing through. Belgian
|
|
# non-energy components run roughly 0.05-0.25 EUR/kWh, so anything at
|
|
# or above 1.0 is the wrong unit and not a tariff.
|
|
steep = sorted({p.eur_per_kwh for p in taxes_table.periods
|
|
if abs(p.eur_per_kwh) >= 1.0})
|
|
if steep:
|
|
problems.append(
|
|
f"{S(TAXES_PROFILE_OPTION)} has {steep} EUR/kWh, which looks "
|
|
"like cents. Divide the figures by 100, or clear this setting.")
|
|
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
|
if p.eur_per_kwh < 0})
|
|
if negative:
|
|
# ⚠️ A WARNING, NOT A REFUSAL. A negative non-energy component is
|
|
# implausible rather than impossible - a prosumer rebate is
|
|
# expressible this way - and refusing a site over it would be this
|
|
# module asserting a fact about Belgian tariff design it cannot
|
|
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
|
# not one.
|
|
_LOG.warning(
|
|
"%s has negative levies %s EUR/kWh, which are subtracted from "
|
|
"the price. This is allowed, but it is usually a sign error.",
|
|
TAXES_PROFILE_OPTION, negative)
|
|
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
|
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
|
"lower the price, and that is usually a sign error.")
|
|
|
|
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
|
# STARTUP RATHER THAN AT 03:00.
|
|
#
|
|
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
|
|
# see because it is not in code: an operator folds their NETWORK CHARGES
|
|
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
|
|
# site is billed against a price ~12 c EUR/kWh high on every hour for as
|
|
# long as nobody re-reads the configuration. §11.4 makes every price
|
|
# defect quiet, so nothing on the status page would ever differ.
|
|
#
|
|
# The card is what grades it. A Belgian tariff card prints the formula,
|
|
# the reference index it assumed AND the resulting price, so the add-on
|
|
# recomputes that printed price from the numbers the operator typed and
|
|
# refuses to start unless the two agree to within CARD_TOLERANCE. An
|
|
# inflated `offset` cannot pass - the printed estimate is the energy
|
|
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
|
|
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
|
|
# MISMATCH between the coefficients and the printed estimate. The one unit
|
|
# error it cannot see is the one applied to BOTH sides at once, because
|
|
# that cancels out of a comparison between them - which is why
|
|
# `implausible_all_in()` below is a second refusal on a second quantity
|
|
# rather than a restatement of this one.
|
|
#
|
|
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
|
|
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
|
|
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
|
# wrong product - reproduces its own printed price and passes. Nothing can
|
|
# catch that.
|
|
spec = None # TARIFF-16 reads this back, below
|
|
raw_formula = opts.get("price_formula", "")
|
|
has_formula = bool(str(raw_formula or "").strip()) \
|
|
or isinstance(raw_formula, dict)
|
|
if pid == "custom_formula" and not has_formula:
|
|
problems.append(
|
|
f"{S('price_provider')} is 'custom_formula', but "
|
|
f"{S('price_formula')} is empty. Copy the formula from your "
|
|
"supplier's tariff card, or choose 'generic_dayahead'.")
|
|
elif has_formula:
|
|
try:
|
|
spec = parse_price_formula(raw_formula)
|
|
except FormulaInvalid as e:
|
|
spec = None
|
|
problems.append(f"“{messages.LABELS['price_formula']}” cannot be used. {e}")
|
|
if spec is not None and pid != "custom_formula":
|
|
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
|
# fighting over one flag - it is one that is simply not wired to
|
|
# anything - so the §14 warning box does not apply. But an option
|
|
# an operator filled in and that decides nothing is exactly the
|
|
# silent loser this module's case (a) is about, so it is said out
|
|
# loud rather than left to be discovered from a bill.
|
|
_LOG.warning(
|
|
"price_formula is filled in, but price_provider is %r, so the "
|
|
"formula is not used. Set price_provider to 'custom_formula' "
|
|
"or clear price_formula.", pid)
|
|
_advise(notes, "warn", ("price_formula", "price_provider"),
|
|
f"{S('price_formula')} is not used, because "
|
|
f"{S('price_provider')} is not 'custom_formula', so clear "
|
|
"it or choose that source.")
|
|
if spec is not None and pid == "custom_formula":
|
|
taxes = _number(opts, "price_taxes_eur_kwh",
|
|
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
|
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
|
|
if band:
|
|
problems.append(band)
|
|
aged = stale_card(spec)
|
|
if aged:
|
|
# ⚠️ WARN, NEVER REFUSE. ENGIE freezes a contract's
|
|
# coefficients for its duration, so a two-year-old card can be
|
|
# exactly right and a site refused over one is a site that
|
|
# cannot start.
|
|
_LOG.warning("%s", aged)
|
|
_advise(notes, "warn", ("price_formula",),
|
|
_card_note("price_formula", spec))
|
|
|
|
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
|
#
|
|
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
|
|
# injection as ONE scalar, and the published market is a line -
|
|
# Eneco `0,1 x BELPEX-H - 1,188` c EUR/kWh, "het injectietarief is
|
|
# dynamisch en kan per uur wijzigen", identical on the January and the
|
|
# September 2026 cards. A scalar cannot hold its own factor (0,100 against
|
|
# 0,102 on the same supplier's consumption leg), cannot hold a negative
|
|
# offset, cannot state that injection is VAT-EXEMPT while consumption is
|
|
# at 6%, and cannot go below zero in the midday hours a PV household
|
|
# actually exports in.
|
|
#
|
|
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
|
|
# inferred: `arbitrage.py` does not read the injection price at all. Both
|
|
# hours it compares are all-in CONSUMPTION prices, by its own
|
|
# accounting-basis comment. No trade turns on this option. What it changes
|
|
# is what the operator is told and what a future export-aware strategy
|
|
# rests on - which is why it is validated here rather than trusted.
|
|
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
|
|
has_export = bool(str(raw_export or "").strip()) \
|
|
or isinstance(raw_export, dict)
|
|
if has_export:
|
|
try:
|
|
xspec = parse_export_formula(raw_export)
|
|
except FormulaInvalid as e:
|
|
xspec = None
|
|
problems.append(f"“{messages.LABELS[EXPORT_FORMULA_OPTION]}” cannot be used. {e}")
|
|
if xspec is not None:
|
|
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
|
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
|
# 0. Printing both beside each other is the only place an operator
|
|
# can see that the add-on knows it - a status page shows one price
|
|
# and no arithmetic.
|
|
_LOG.info(
|
|
"%s is set: injection priced as (%g x index %+g) x "
|
|
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
|
|
"%+.5f EUR/kWh offset. No part of the add-on reads it yet: "
|
|
"trading prices the sell side as displaced import and "
|
|
"compares two all-in consumption prices, so this setting "
|
|
"changes no decision the add-on takes today. It is what you "
|
|
"are shown, and the shape a future export-aware strategy "
|
|
"would read.",
|
|
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
|
|
xspec.vat_pct, xspec.units, xspec.passthrough,
|
|
xspec.offset_eur_kwh)
|
|
if spec is not None:
|
|
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
|
|
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
|
|
# somewhere" is satisfied by the two legs transposed, which is
|
|
# the sentence telling an operator that the add-on has their
|
|
# VAT treatment exactly backwards.
|
|
_LOG.info(
|
|
"the two legs are priced independently: consumption "
|
|
"factor %g at vat_pct %g, injection factor %g at vat_pct "
|
|
"%g. In Belgium those differ - \"injectietarieven zijn "
|
|
"vrijgesteld van btw\" while consumption is at 6%% - and "
|
|
"%s is the option that makes the difference "
|
|
"expressible; a single scalar could not state it.",
|
|
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
|
|
EXPORT_FORMULA_OPTION)
|
|
if not xspec.card_graded:
|
|
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
|
|
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
|
|
# required because a Belgian card prints the consumption
|
|
# formula, the reference index AND the resulting price. Eneco's
|
|
# INJECTION formula is printed with no price beside it, so on
|
|
# that card there is nothing to reproduce. Requiring a block
|
|
# here would make the market's own published line
|
|
# inexpressible; inventing the estimate would grade the
|
|
# coefficients against arithmetic done with the same
|
|
# coefficients. So the leg is accepted, the two coefficient
|
|
# bounds in `implausible_injection()` are applied, and the
|
|
# operator is told which of the two checks did NOT run.
|
|
_LOG.warning(
|
|
"%s has no \"card\" block, so its numbers are not checked "
|
|
"against a printed price, only their shape (pass-through "
|
|
"%.4g, offset %+.5f EUR/kWh).",
|
|
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
|
xspec.offset_eur_kwh)
|
|
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
|
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
|
"only the shape of its numbers is checked.")
|
|
_LOG.debug(
|
|
"Accepted because some suppliers (Eneco) print the "
|
|
"injection formula with no price beside it. If your card "
|
|
"prints an injection estimate, add {\"month\": ..., "
|
|
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
|
|
"and the add-on refuses to start unless your numbers "
|
|
"reproduce it.")
|
|
else:
|
|
xaged = stale_card(xspec)
|
|
if xaged:
|
|
_LOG.warning("%s", xaged)
|
|
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
|
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
|
|
|
# ⚠️ 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, so a price "
|
|
"feed outage leaves no tariff at all. Fill in static_profile as a "
|
|
"fallback.", pid)
|
|
_advise(notes, "warn", ("static_profile",),
|
|
f"{S('static_profile')} is empty, so the site has no tariff "
|
|
"at all while the price feed is down.")
|
|
|
|
# ⚠️ 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"{S('cycle_cost_threshold_eur')} is "
|
|
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
|
|
"in EUR/kWh.")
|
|
|
|
if arbitrage_on(opts):
|
|
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
|
problems.append(
|
|
f"{S('arbitrage_enabled')} is on, but the site has no prices. "
|
|
f"Set {_labels(['price_provider', 'static_profile'], 'or')}, "
|
|
"or turn trading off.")
|
|
if not is_enabled(opts):
|
|
problems.append(
|
|
f"{S('arbitrage_enabled')} is on, but no meter is set in "
|
|
f"{S('meter_source')}. Choose a meter, or turn trading 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"{S('arbitrage_soc_floor')} and {S('arbitrage_soc_ceiling')} "
|
|
"must both be numbers, in %.")
|
|
elif floor >= ceiling:
|
|
problems.append(
|
|
f"{S('arbitrage_soc_floor')} is {floor:g} %, not below "
|
|
f"{S('arbitrage_soc_ceiling')} at {ceiling:g} %. Make the lower "
|
|
"limit the smaller number.")
|
|
|
|
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
|
problems.append(
|
|
f"{S('price_provider')} is 'manual_csv', but "
|
|
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
|
"choose 'off'.")
|
|
|
|
return problems
|
|
|
|
|
|
def _card_note(option: str, spec) -> str:
|
|
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
|
"the new figures if your contract changed.")
|
|
|
|
|
|
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
|
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
|
WATT_ENTITY_KEYS = (
|
|
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
|
"p1_phase_net_entities", "p1_phase_import_entities",
|
|
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
|
|
|
|
|
# The meter entities each `meter_source` reads through Home Assistant.
|
|
METER_ENTITY_KEYS = {
|
|
"off": ("meter_entity",),
|
|
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
|
|
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
|
|
"p1_phase_import_entities", "p1_phase_export_entities")}
|
|
|
|
|
|
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
|
"""(key, inverter name or "", entity id) for each entity the add-on reads
|
|
with these options. Keys the chosen meter source or roster shape ignores
|
|
are left out, so a stale default is not reported."""
|
|
out = []
|
|
|
|
def add(key, value, where=""):
|
|
for e in (value if isinstance(value, list) else [value]):
|
|
if isinstance(e, str) and e.strip():
|
|
out.append((key, where, e.strip()))
|
|
|
|
source = str(opts.get("meter_source") or "off").strip()
|
|
for key in METER_ENTITY_KEYS.get(source, ()):
|
|
add(key, opts.get(key))
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
|
add(key, opts.get(key))
|
|
for i, entry in enumerate(entries):
|
|
if isinstance(entry, dict):
|
|
where = _unit_label(opts, i).strip().strip("()")
|
|
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
|
add(key, entry.get(key), where)
|
|
solar = [e for e in (opts.get("pv_entities") or []) if str(e or "").strip()]
|
|
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
|
|
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
|
|
add(key, opts.get(key))
|
|
return out
|
|
|
|
|
|
def config_notes(opts: dict, *, entities: dict | None = None,
|
|
month_peak_w=None) -> list[Note]:
|
|
"""UX-13: warnings that need live data, for the settings page. Never a
|
|
refusal, and nothing here changes what `validate_startup_config()` says.
|
|
|
|
`entities` is Home Assistant's entity list by id (app/haentities.py), or
|
|
None when it could not be read, in which case those checks are skipped.
|
|
`month_peak_w` is this month's peak so far, or None.
|
|
"""
|
|
notes: list[Note] = []
|
|
if entities is not None:
|
|
_entity_notes(notes, opts, entities)
|
|
cap = _number(opts, "peak_cap_w", None)
|
|
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
|
_advise(notes, "warn", ("peak_cap_w",),
|
|
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
|
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
|
return notes
|
|
|
|
|
|
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
|
"""A note for each entity these options read that Home Assistant does not
|
|
have, or that reads in kW where watts are taken. A missing entity whose
|
|
key is in `bad` is a "bad" note, which blocks the setup wizard."""
|
|
for key, where, entity in _entities_read(opts):
|
|
row = entities.get(entity)
|
|
if row is None:
|
|
_advise(notes, "bad" if key in bad else "warn", (key,),
|
|
f"Home Assistant has no entity {entity}, which "
|
|
f"{S(key, where)} names.")
|
|
continue
|
|
unit = str(row.get("unit") or "")
|
|
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
|
_advise(notes, "warn", (key,),
|
|
f"{entity} reads in {unit}, but {S(key, where)} is "
|
|
"taken as watts, so pick a sensor in W.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# UX-13 P4: the setup wizard's last step
|
|
# --------------------------------------------------------------------------- #
|
|
# Below this a battery or solar reading counts as idle for a sign check.
|
|
IDLE_W = 50.0
|
|
# Meter + solar + battery is the house's use, never below zero. Below minus
|
|
# this, read seconds apart, a direction is reversed (a battery giving power).
|
|
BALANCE_W = 300.0
|
|
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
|
# MQTT) is only known live when the running options read the same one.
|
|
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
|
|
|
|
|
def power_request_entities(opts: dict) -> list:
|
|
"""The power request entity of each inverter these options name: the flat
|
|
one, or each entry's. Blank ones are left out."""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
values = [opts.get("setpoint_entity")]
|
|
else:
|
|
values = [e.get("setpoint_entity") for e in entries
|
|
if isinstance(e, dict)]
|
|
return [str(v).strip() for v in values
|
|
if isinstance(v, str) and v.strip()]
|
|
|
|
|
|
def _reading(entities, entity, convert=False):
|
|
"""(number, unit) of an entity's state, or (None, state text). The number
|
|
is as the add-on reads it: raw for the meter and battery power, which it
|
|
takes as watts, and in W for solar (`convert`), which pv.py converts."""
|
|
row = (entities or {}).get(entity) or {}
|
|
unit = str(row.get("unit") or "")
|
|
try:
|
|
value = float(row.get("state"))
|
|
except (TypeError, ValueError):
|
|
return None, str(row.get("state") or "nothing")
|
|
if value != value or value in (float("inf"), float("-inf")):
|
|
return None, str(row.get("state"))
|
|
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
|
|
|
|
|
|
def _signed(w) -> str:
|
|
return ("-" if w < 0 else "") + messages.watts(w)
|
|
|
|
|
|
def _cards(opts: dict) -> list:
|
|
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
return [("", {k: opts.get(k) for k in (
|
|
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
|
|
out = []
|
|
for i, entry in enumerate(entries):
|
|
if isinstance(entry, dict):
|
|
name = _unit_label(opts, i).strip().strip("()")
|
|
out.append((f" for {name}" if name else "", entry))
|
|
return out
|
|
|
|
|
|
def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
|
meter_now_w=None, prices_known=None) -> list[Note]:
|
|
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
|
|
|
|
Live checks on the proposed options: the meter reads, each power request
|
|
entity exists in Home Assistant (a write to a missing one is answered 200,
|
|
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
|
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
|
request or meter entity, which the wizard will not save. Reads nothing itself:
|
|
`entities` is Home Assistant's list by id (None when it did not answer),
|
|
`running` the options this process started with, `meter_now_w` its meter
|
|
reading and `prices_known` how many of today's hours have a price.
|
|
"""
|
|
notes: list[Note] = []
|
|
if entities is None:
|
|
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
|
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
|
"answering, so press Check again once it is back.")
|
|
else:
|
|
# A missing meter entity is red too (UX-15): with the kettle test
|
|
# skipped, nothing else on the way to the save would catch it.
|
|
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
|
|
_entity_notes(notes, opts, entities,
|
|
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
|
|
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
|
batts = [_card_checks(notes, who, entry, entities)
|
|
for who, entry in _cards(opts)]
|
|
solar = []
|
|
if entities is not None:
|
|
flipped = pv.inverted(opts)
|
|
for entity in pv.entities(opts):
|
|
w, _unit = _reading(entities, entity, convert=True)
|
|
if entity not in entities or w is None:
|
|
solar.append(None)
|
|
continue
|
|
if w < -IDLE_W and entity not in flipped:
|
|
solar.append(None)
|
|
_advise(notes, "warn", ("pv_invert_entities",),
|
|
f"{entity} reads below zero now, so turn on its "
|
|
"“Reads negative” switch in the Solar step.")
|
|
else:
|
|
w = -w if entity in flipped else w
|
|
solar.append(w)
|
|
_advise(notes, "ok", ("pv_entity",),
|
|
f"{entity} reads {_signed(w)} of solar production "
|
|
"now.")
|
|
# A battery sensor that reads the wrong way while giving power makes the
|
|
# house use less than nothing. Only with every term known: a missing
|
|
# solar reading could be the difference.
|
|
known = [meter] + batts + solar
|
|
if solar and batts and all(w is not None for w in known) \
|
|
and sum(known) < -BALANCE_W:
|
|
_advise(notes, "warn", ("batt_invert",),
|
|
f"The readings add up to {_signed(sum(known))} of house "
|
|
"use, so check each "
|
|
"battery's “Battery power sign is reversed” switch in step 2.")
|
|
_price_check(notes, opts, running, prices_known)
|
|
return notes
|
|
|
|
|
|
def _meter_check(notes, opts, entities, running, meter_now_w):
|
|
"""The meter line. Returns the grid reading in W (positive: taken from
|
|
the grid) as the add-on will read it, or None when it is not known."""
|
|
source = str(opts.get("meter_source") or "off").strip()
|
|
if source == "off":
|
|
parts = [(opts.get("meter_entity"), 1.0)]
|
|
elif source == SOURCE_HA_SIGNED:
|
|
parts = [(opts.get("p1_net_entity"), 1.0)]
|
|
elif source == SOURCE_HA:
|
|
parts = [(opts.get("p1_import_entity"), 1.0),
|
|
(opts.get("p1_export_entity"), -1.0)]
|
|
else:
|
|
same = isinstance(running, dict) and all(
|
|
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
|
if same and meter_now_w is not None:
|
|
_meter_line(notes, float(meter_now_w))
|
|
return float(meter_now_w)
|
|
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
|
"this meter itself, so Home shows its reading after "
|
|
"the restart.")
|
|
return None
|
|
if entities is None:
|
|
return None
|
|
total = 0.0
|
|
for entity, sign in parts:
|
|
entity = str(entity or "").strip()
|
|
if not entity or entity not in entities:
|
|
return None # a refusal or a missing-entity note says so
|
|
w, text = _reading(entities, entity)
|
|
if w is None:
|
|
_advise(notes, "warn", ("meter_source",),
|
|
f"{entity} reads {text} now, so check it in Home "
|
|
"Assistant before you start.")
|
|
return None
|
|
total += sign * w
|
|
if source == "off" and opts.get("meter_invert"):
|
|
total = -total
|
|
_meter_line(notes, total)
|
|
return total
|
|
|
|
|
|
def _meter_line(notes, w) -> None:
|
|
if abs(w) < 1.0:
|
|
text = "The meter reads 0 W now."
|
|
elif w > 0:
|
|
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
|
else:
|
|
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
|
_advise(notes, "ok", ("meter_source",), text)
|
|
|
|
|
|
def _card_checks(notes, who, entry, entities):
|
|
"""The checks for one inverter's card. Returns its battery power in W,
|
|
positive while giving power, or None when it is not known."""
|
|
if entities is None:
|
|
return None
|
|
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
|
if setpoint and setpoint in entities:
|
|
_advise(notes, "ok", ("setpoint_entity",),
|
|
f"Home Assistant has {setpoint}, the power request "
|
|
f"entity{who}.")
|
|
soc = str(entry.get("soc_entity") or "").strip()
|
|
if not soc:
|
|
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
|
f"level entity{who}, so pick one in step 2 or the add-on "
|
|
"holds 0 W.")
|
|
elif soc in entities:
|
|
level, _unit = _reading(entities, soc)
|
|
if level is None or not 0.0 <= level <= 100.0:
|
|
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
|
f"level{who} reads nothing now, so check {soc}.")
|
|
else:
|
|
_advise(notes, "ok", ("soc_entity",),
|
|
f"The battery level{who} reads {level:.0f} %.")
|
|
batt = str(entry.get("batt_entity") or "").strip()
|
|
if batt and batt in entities:
|
|
w, _unit = _reading(entities, batt)
|
|
if w is None:
|
|
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
|
f"power{who} reads nothing now, so check {batt}.")
|
|
return None
|
|
w = -w if entry.get("batt_invert") else w
|
|
if abs(w) < IDLE_W:
|
|
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
|
"idle now, so check its direction on Home later.")
|
|
else:
|
|
doing = "giving power" if w > 0 else "charging"
|
|
_advise(notes, "info", ("batt_invert",),
|
|
f"The battery{who} reads {messages.watts(w)} {doing} "
|
|
"now, so check the inverter shows the same.")
|
|
return w
|
|
return None
|
|
|
|
|
|
def _price_check(notes, opts, running, prices_known) -> None:
|
|
provider = str(opts.get("price_provider") or "off").strip()
|
|
profile = str(opts.get("static_profile") or "").strip()
|
|
if provider in ("", "off"):
|
|
if profile:
|
|
_advise(notes, "ok", ("static_profile",),
|
|
"Your prices come from your contract, so they are "
|
|
"always known.")
|
|
else:
|
|
_advise(notes, "info", ("price_provider",),
|
|
"No prices are set, so the batteries do not charge "
|
|
"on price.")
|
|
return
|
|
same = (isinstance(running, dict)
|
|
and str(running.get("price_provider") or "") == provider)
|
|
if same and prices_known:
|
|
_advise(notes, "ok", ("price_provider",),
|
|
f"Prices for today are in, for {prices_known} of 24 hours.")
|
|
else:
|
|
_advise(notes, "info", ("price_provider",),
|
|
"Prices are fetched after the restart, and Home shows them.")
|
|
|
|
|
|
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("The add-on did not start, because its settings have problems.")
|
|
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
|
|
_LOG.error("They are on the add-on's Configuration tab.")
|
|
_LOG.error("-" * 72)
|
|
for i, problem in enumerate(problems, 1):
|
|
_LOG.error(" %d. %s", i, problem)
|
|
_LOG.error("=" * 72)
|