Files
goodwe-addon/goodwe_controller/app/validate.py
T
glenn schrooyenandClaude Opus 5.5 9b1fa758ad
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
OPT-17: the solar forecast can come from several entities, summed
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.

- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
  day is summed. Hourly splits are merged when every member has one, else
  the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
  day's forecast unusable (no partial sum). A caution row names it by
  position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
  the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.

DECISIONS/OPT-17-several-forecast-entities.md has the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 02:55:58 +02:00

2142 lines
116 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.
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
make the day plan buy everything or nothing, which no one means.
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
import re
from typing import NamedTuple
from . import dayplan, messages, pv, solarfc
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_MARGIN_RANGE_W, 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.")
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
problems += _margin_problems(opts)
problems += _forecast_problems(opts)
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
if integ_w is not None and max_w is not None and integ_w > max_w:
_LOG.warning(
"integrator_max_w %.0f W is above max_w %.0f W. 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))
for key in FORECAST_KEYS:
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.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
# --------------------------------------------------------------------------- #
# 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 _margin_problems(opts: dict) -> list[str]:
"""Rule 12: `peak_margin_w` is a whole number in the schema's range, and
below the peak limit, which would leave nothing to aim at."""
raw = opts.get("peak_margin_w")
margin = _number(opts, "peak_margin_w",
PEAK_OPTION_DEFAULTS["peak_margin_w"])
lo, hi = PEAK_MARGIN_RANGE_W
if margin is None or margin != int(margin) or not lo <= margin <= hi:
return [f"{S('peak_margin_w')} must be a whole number from {lo} to "
f"{hi} W. It is {messages.shown(raw)}."]
cap = _number(opts, "peak_cap_w", None)
if cap is not None and margin >= cap:
return [f"{S('peak_margin_w')} is {margin:.0f} W, not below the "
f"{S('peak_cap_w')} of {cap:.0f} W. Lower the margin."]
return []
TRUST_RANGE_PCT = (30, 100)
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
def _forecast_problems(opts: dict) -> list[str]:
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
each forecast entity set is a sensor. OPT-17: a day names its forecast
in the single key or in the list, not both."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
lo, hi = TRUST_RANGE_PCT
if trust is None or trust != int(trust) or not lo <= trust <= hi:
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
f"to {hi} %. It is {messages.shown(raw)}.")
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
e = opts.get(one)
if e is not None and e != "" and (
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
out.append(f"{S(one)} must be a sensor, like "
f"sensor.energy_production_today_remaining. It is "
f"{messages.shown(e)}.")
raw = opts.get(many)
if raw is None:
raw = []
if not isinstance(raw, list):
out.append(f"{S(many)} must be a list of sensors. It is "
f"{messages.shown(raw)}.")
continue
listed = [x for x in raw if x is not None
and not (isinstance(x, str) and not x.strip())]
for x in listed:
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
out.append(f"{S(many)} must list only sensors, like "
f"sensor.energy_production_today_remaining. "
f"It has {messages.shown(x)}.")
if e and listed:
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
f"list and clear the single one.")
return out
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)