Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s

CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-04 12:23:50 +02:00
co-authored by Claude Opus 5
13 changed files with 2090 additions and 120 deletions
+109 -25
View File
@@ -134,10 +134,17 @@ from .watchdog import SignWatchdog, SignWatchdogConfig
# constant the firmware and validate.py already share.
LINK_STALE_S = 30.0
# The one inverter this add-on drives today. ⚠️ A NAME, NOT A CONFIGURATION -
# it exists so that a per-inverter claim has an actuator to be filed ON, which
# is what makes the site/unit split something a check can grade rather than a
# comment. It lives here rather than in main.py now that the roster does.
# The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A
# CONFIGURATION - it exists so that a per-inverter claim has an actuator to be
# filed ON, which is what makes the site/unit split something a check can grade
# rather than a comment. It lives here rather than in main.py now that the
# roster does.
#
# ⚠️ CFG-04 DID NOT MAKE THIS "THE FIRST ONE". It is the name the flat-keys path
# gives its one unit, and nothing else; a declared `inverters:` roster names its
# own entries and this constant is not consulted. The store migration
# (`migrate_unit_keys`) still targets it, because the flat pre-DEV-02 keys can
# only ever have belonged to a single-inverter install.
UNIT_A = "inverter-a"
# The per-unit keys this object keeps in the Store, namespaced by unit name.
@@ -308,27 +315,104 @@ class Inverter:
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
def build_roster(opts: dict, store, sign_cfg=None) -> list:
"""The site's inverters, in roster order. TODAY THAT IS EXACTLY ONE.
def roster_entries(opts: dict) -> list:
"""The `inverters:` option, normalised to a list of dicts. Never None.
⚠️ INVERTER B DOES NOT EXIST YET (~2 weeks out) AND THIS FUNCTION MUST NOT
INVENT IT. There is no `inverters:` list in config.yaml, no second
`setpoint_entity`, and no measured rating, ramp or lag for a second device -
SPIKE-04 measures those and CFG-04 shapes the option that carries them. What
this card delivers is that the CODE no longer assumes there is one; adding
the second entry is a config change to THIS FUNCTION and to nothing else,
which is the point.
So: unit A is built from the flat options, exactly as the shipped scalars
were, and the flat options keep their names. An install that upgrades reads
identically - same entities, same rating, same behaviour - which is AC 8.
Separate from `build_roster()` because validate.py needs to grade the SAME
list this builds from, and a second transcription of "what counts as an
entry" is a second answer to it. A non-list, or a list with non-dict
members, comes back as it was so the validator can name what it found -
dropping the junk here would hand the validator a clean list and a config
nobody refused.
"""
return [Inverter(
UNIT_A,
setpoint_entity=opts.get("setpoint_entity", ""),
soc_entity=opts.get("soc_entity", ""),
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
store=store, sign_cfg=sign_cfg)]
entries = opts.get("inverters")
if entries is None or entries == "":
return []
if not isinstance(entries, (list, tuple)):
return [entries]
return list(entries)
def build_roster(opts: dict, store, sign_cfg=None) -> list:
"""The site's inverters, in roster order.
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
`inverters:` empty?
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
exactly as the shipped scalars did. This is what every install
that upgrades into this version reads, without an operator
touching anything, and it is byte-identical to what DEV-02
shipped. config.yaml's default is `[]` for precisely that reason,
and Supervisor 2026.08.0 refuses the whole options blob without
it - see the options-block note on `inverters:`.
NON-EMPTY each entry describes one inverter and the flat keys describe
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
because "this unit's number came from somewhere else" is the
defect DEV-02 spent a card removing and a fallback here would
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
are required of every entry - the schema enforces it on the
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
and the checks, which reach this function with any dict at all.
⚠️ THIS FUNCTION REFUSES NOTHING, AND THAT IS ON PURPOSE. A refusal here
happens inside `Controller.__init__`, before the startup gate has run and
before anything can render the reason to an operator; validate.py's rule 9
is the place that says no, with a message, through `refuse()`. So this
builds the most honest roster it can out of whatever it was handed - a
missing name becomes a positional one, a missing rating becomes 0.0 W - and
every one of those states is something rule 9 has already refused by the
time a control loop exists. Keep the two in step: a shape this tolerates
that rule 9 does not name is a shape that runs.
"""
entries = roster_entries(opts)
if not entries:
# ⚠️ THE UNCHANGED PATH. Same name, same entities, same rating, same
# store keys as before CFG-04 - an install that upgrades reads
# identically, which is AC 3.
return [Inverter(
UNIT_A,
setpoint_entity=opts.get("setpoint_entity", ""),
soc_entity=opts.get("soc_entity", ""),
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
store=store, sign_cfg=sign_cfg)]
roster = []
for i, entry in enumerate(entries):
entry = entry if isinstance(entry, dict) else {}
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
# both called `inverter-a` would share one Store namespace, so one
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
# a nameless entry outright; this only decides what the refusal is
# allowed to call it in the meantime.
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
roster.append(Inverter(
name,
setpoint_entity=entry.get("setpoint_entity", ""),
soc_entity=entry.get("soc_entity", ""),
batt_entity=entry.get("batt_entity", ""),
batt_invert=bool(entry.get("batt_invert")),
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
# not state is not the flat one, it is a rating rule 9 refuses.
max_w=_entry_max_w(entry),
store=store, sign_cfg=sign_cfg))
return roster
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
0.0 rather than a borrowed number, and rather than an exception: rule 9
refuses `max_w` missing or unparseable by name, and a roster that cannot be
built at all would refuse it with a traceback instead.
"""
try:
return float(entry.get("max_w"))
except (TypeError, ValueError):
return 0.0
+61 -31
View File
@@ -52,7 +52,8 @@ from .hass import HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
from .inverter import LINK_STALE_S, UNIT_A, build_roster
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from . import peakguard
@@ -61,7 +62,8 @@ from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .validate import ConfigRefused, refuse, validate_startup_config
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
@@ -212,6 +214,24 @@ class Controller:
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
self.inverters = build_roster(opts, store, sign_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
# operator looking at the Configuration page sees all four with values
# in them. validate.py rule 9(e) refuses the one that is genuinely
# dangerous - a flat `setpoint_entity` naming an actuator nothing will
# write - and the rest cannot be refused (the schema gives them no blank
# value), so they are DISCLOSED instead: one line in §15's action log,
# at every start, naming the roster it built and the keys it ignored.
# Silence here is how "the site is running at half the rating I typed"
# takes a week to notice.
if roster_entries(opts):
self.log_event(
"roster: %d inverters from `inverters:` - %s. The flat "
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
@@ -2600,37 +2620,47 @@ class Controller:
inv.setpoint_entity)
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_entity", "")
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
# exists to remove: a max_w above the device's rating meant
# write_setpoint() silently clamped and the configured ceiling was
# never the real one.
#
# amain() already ran this gate before anything started. It runs
# AGAIN here for one case the gate cannot cover: ESPHome is often
# not up yet when the add-on boots, so hass.limits() returned
# (None, None) and the range checks were skipped rather than
# guessed at. This is the first moment the device's real rating is
# known. If it conflicts, refuse now - stopping deterministically
# through the normal shutdown, which commands 0 W on the way out.
#
# ⚠️⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A
# READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file.
# `entity` is the FLAT `setpoint_entity`, which a per-inverter config
# leaves blank (validate rule 9(e) requires it), so on the very config
# this add-on now supports the entire re-check was SKIPPED: no
# inverter's `max_w` was ever graded against the device it is written
# to, and the skip was silent. The flat gate still guards the flat
# rules, because they read flat options and one device's range; the
# per-unit rule runs unconditionally, off the roster, and grades each
# unit against ITS OWN ESPHome. At one inverter `validate_unit_ranges()`
# skips the unit the flat call already graded, so nothing is reported
# twice and the N=1 path is unchanged.
problems = []
if entity:
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
# exists to remove: a max_w above the device's rating meant
# write_setpoint() silently clamped and the configured ceiling was
# never the real one.
#
# amain() already ran this gate before anything started. It runs
# AGAIN here for one case the gate cannot cover: ESPHome is often
# not up yet when the add-on boots, so hass.limits() returned
# (None, None) and the range checks were skipped rather than
# guessed at. This is the first moment the device's real rating is
# known. If it conflicts, refuse now - stopping deterministically
# through the normal shutdown, which commands 0 W on the way out.
# ⚠️ GRADED AGAINST THE FIRST UNIT'S RANGE, because `validate.py`
# grades the FLAT options - `max_w`, `setpoint_entity` - which are
# unit A's. A per-unit validator arrives with CFG-04's per-unit
# options; inventing one here would be this file asserting a schema
# for a config that does not exist yet.
_first = self.inverters[0]
problems = validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0])
# Signal, then return WITHOUT entering the loop. amain() wakes
# on this, runs the normal wind-down (which commands 0 W) and
# re-raises ConfigRefused afterwards, so the exit code is 78 and
# the inverter is left at zero rather than latched.
self.stop_event.set()
return
problems += validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0])
# Signal, then return WITHOUT entering the loop. amain() wakes
# on this, runs the normal wind-down (which commands 0 W) and
# re-raises ConfigRefused afterwards, so the exit code is 78 and
# the inverter is left at zero rather than latched.
self.stop_event.set()
return
last_grid = object()
heartbeat = float(self.o.get("heartbeat_s", 10))
+323 -17
View File
@@ -151,6 +151,32 @@ NOT READY banner already names the problem, (b) does not apply.
permits equality because equality there leaves one reachable point that
works; here it leaves none.
9. An `inverters:` roster that cannot mean what it says. [(a) and (b)]
CFG-04's five cases are enumerated on `_roster_problems()`. Three of them
are (b) - an entry with no `setpoint_entity`, or no rating, is a unit that
takes a share of the site target every cycle and delivers none of it, on a
status page that reports a site running at full strength. Two are (a): two
entries sharing a name share one Store namespace, so one unit's SAFETY-02
latch IS the other's and an operator clearing one clears both; and a flat
`setpoint_entity` left set beside a declared roster is an actuator the
configuration names and nothing writes.
⚠️ RULES 5 AND 6 CHANGED SHAPE FOR THIS, and that is the part worth
reading. Both compared a threshold against the flat `max_w`, which was
right while there was exactly one inverter and is a PERMISSIVE READER the
moment there are two: `max_w: 5000` left over from a single-inverter
install, beside a roster of two 2000 W units, passed rule 5 at
`sign_disagree_w: 3000` while the watchdog could fire on NEITHER unit -
rule 5's own failure mode, reached through rule 5. They now read
`unit_caps()`, which is the roster's caps and, at one inverter, is
`[max_w]` exactly.
What rule 9 deliberately does NOT do is grade a rating against a device.
`dev_min`/`dev_max` here are one device's range and there are now N of
them, none of which has answered at startup. `validate_unit_ranges()` is
the per-unit form and main.py runs it from `run_control()`, once every
unit's own ESPHome has replied.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -203,6 +229,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from .arbitrage import enabled as arbitrage_on
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
@@ -309,6 +336,229 @@ def actuator_writers(opts: dict) -> dict:
return census
def unit_caps(opts: dict) -> list[float]:
"""Each configured inverter's supervised cap, in roster order.
⚠️ WHY THIS EXISTS AT ALL, AND IT IS THE PERMISSIVE-READER PROBLEM IN THIS
FILE. Rules 5 and 6 both compare a threshold against "max_w", and both of
them are really comparing it against WHAT ONE UNIT MAY BE COMMANDED - the
arbiter files `supervised` per inverter, from `inv.max_w`, and there is no
such thing as a site rating on the wire. At one inverter those are the same
number and reading the flat option was right. At two they are not: a config
with `max_w: 5000` left over from a single-inverter install and a roster of
two 2000 W units would pass rule 5 at `sign_disagree_w: 3000` while the
watchdog could not fire on EITHER unit - the disarmed-guard-reporting-armed
state those rules exist to refuse, reached through the rule itself.
So the caps come from the roster the add-on will actually build
(`inverter.roster_entries()`, not a second reading of the option), and fall
back to the flat `max_w` only where that roster is the flat one too.
"""
return [cap for _, cap in unit_caps_indexed(opts)]
def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
"""`unit_caps()`, but each cap carries the ROSTER INDEX it came from.
⚠️ WHY THE INDEX HAS TO TRAVEL WITH THE NUMBER. This function SKIPS entries
with no usable `max_w`, so position i in the caps list is not position i in
the roster the moment one entry is malformed. Rules 5 and 6 used to feed a
caps-list index straight into `_unit_label()`, which indexes `entries` - so
on a roster whose first entry has a junk `max_w`, the refusal named the
WRONG INVERTER. An operator told to lower a number on `inverter-a` when the
number is on `inverter-b` is being sent to the wrong device by the very
message that exists to stop that. Rule 9(d) already refuses such a config so
the two lists cannot desynchronise in shipped use - but "unreachable" is a
claim about today's rule ordering, not a property of this function, and
`validate_startup_config()` is also `sim/inject.py`'s entry point.
"""
entries = roster_entries(opts)
if not entries:
cap = _number(opts, "max_w", 2000.0)
return [] if cap is None else [(0, cap)]
caps: list[tuple[int, float]] = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
cap = _number(entry, "max_w", None)
if cap is not None:
caps.append((index, cap))
return caps
def _unit_label(opts: dict, index: int) -> str:
""""" at a single-inverter config, " (inverter-b)" at a roster.
The messages below have to read identically on the install that upgrades
into this version - it is the same configuration, refused for the same
reason - and have to NAME THE UNIT the moment there is more than one, or an
operator with three inverters is told a number is wrong and not which one.
"""
entries = roster_entries(opts)
if len(entries) <= 1:
return ""
entry = entries[index] if index < len(entries) else {}
name = ""
if isinstance(entry, dict):
name = str(entry.get("name") or "").strip()
return f" ({name or f'inverters[{index}]'})"
def _roster_problems(opts: dict) -> list[str]:
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
what it says.
⚠️ THE SUPERVISOR ALREADY ENFORCES MOST OF THIS AND THAT IS NOT A REASON TO
DROP IT. `config.yaml`'s schema makes `name`, `setpoint_entity` and `max_w`
required per entry, and Supervisor 2026.08.0 refuses a blob that omits one
by name - measured, not assumed. But `validate_startup_config()` is also
`sim/inject.py`'s entry point and every check's, and those reach it with a
dict the Supervisor never saw; the same argument `price_provider` makes two
rules up. A rule the Supervisor duplicates costs nothing and covers the
callers it does not.
Five things are refused, and each is a config that would otherwise RUN:
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
can name it; without the rule the roster would be one inverter built
out of a string's `.get`, i.e. an immediate AttributeError.
b. An entry with no usable `name`. Store keys are namespaced BY NAME
(`store.unit_key`), so two nameless entries would share one namespace
and one unit's SAFETY-02 latch would silently be the other's.
c. Two entries with the SAME name - (b) reached the long way round.
d. An entry with no `setpoint_entity`, or no usable `max_w`. The first is
a unit this controller cannot command; the second is a unit whose
supervised claim would be `+-0 W`, which is a unit pinned at zero by a
missing key rather than by a decision.
e. A flat `setpoint_entity` alongside a declared roster. The flat keys are
the SINGLE-inverter shape; leaving one set while a roster is declared
means one of two things and neither is safe. If it names an entity an
entry also names, SAFETY-28's census below already refuses it as two
writers on one actuator. If it names a DIFFERENT one, nothing writes
it, the site runs quietly smaller than the configuration reads, and the
one place it would show up is a status page nobody compares against the
config. Refused rather than ignored, for §1.5's reason and for this
card's: a config that silently becomes a different config is worse than
one that refuses to load.
"""
problems: list[str] = []
raw = opts.get("inverters")
if raw is None or raw == "" or raw == []:
return problems
if not isinstance(raw, (list, tuple)):
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
"LIST of inverter objects - one per inverter, each naming its "
"own name, setpoint_entity and max_w. A single inverter needs "
"no list at all: leave `inverters` empty and the flat "
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
entries = roster_entries(opts)
seen: dict = {}
for i, entry in enumerate(entries):
where = f"inverters[{i}]"
if not isinstance(entry, dict):
problems.append(
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
"must be an object with at least name, setpoint_entity and "
"max_w.")
continue
name = str(entry.get("name") or "").strip()
if not name:
problems.append(
f"{where} has no name. Every inverter needs one: the Store "
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
"write bookkeeping under it, so two unnamed units would share "
"one namespace and a trip caused by one would latch the other. "
"Any short identifier works - inverter-a, inverter-b, garage.")
elif name in seen:
problems.append(
f"{where} and {seen[name]} are both called {name!r}. The Store "
"keys every per-unit latch under the unit's name, so two units "
"with one name share one SAFETY-02 latch and one heartbeat "
"deadline - a trip caused by either would stop both, and an "
"operator clearing it would clear both. Give them different "
"names.")
else:
seen[name] = where
who = f"{where} ({name})" if name else where
if not str(entry.get("setpoint_entity") or "").strip():
problems.append(
f"{who} has no setpoint_entity, so this add-on cannot command "
"it. It would sit in the roster, take a share of the site "
"target every cycle and deliver none of it, while the status "
"page showed a site running at full strength. Give it the "
"ESPHome number entity that carries ITS setpoint, or remove "
"the entry.")
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append(
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
"the supervised cap on what may be written to THIS inverter, it "
"is not shared with any other unit and it is deliberately not "
"inherited from the flat max_w - a rating borrowed from a "
"sibling is exactly what DEV-02 removed. State this unit's own "
"rating in watts.")
flat_sp = str(opts.get("setpoint_entity") or "").strip()
if entries and flat_sp:
problems.append(
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
"read at all once a roster exists, so this config declares an "
"actuator that nothing will ever write - a site running quietly "
"smaller than its own configuration. Move that inverter into the "
"list as its own entry and clear setpoint_entity.")
return problems
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
"""Rule 2, per inverter, against each unit's OWN reported device range.
⚠️ THE READER THIS REPLACES FAILED PERMISSIVE, WHICH IS THE DIRECTION THAT
HIDES A DEFECT. `run_control()` re-ran rule 2 behind `if
self.o.get("setpoint_entity")` - the FLAT key - because the flat options
were the only per-device options there were. Declare a roster and that key
is blank by rule 9(e), so the whole re-check was skipped and NO inverter's
`max_w` was ever graded against the device it is written to. That is rule
2's own failure mode restored by a config shape: the operator's stated
ceiling is not the ceiling and nothing says which won.
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
guessed at, for the reason in this module's docstring: ESPHome is often not
up when the add-on boots and (None, None) means "not read", not "zero".
`skip_entity` is the flat `setpoint_entity` when the caller has already
graded it through `validate_startup_config(opts, dev_min, dev_max)`. At a
single-inverter config that is the one unit, and grading it twice would
report the same conflict twice under two wordings.
"""
problems: list[str] = []
skip = str(skip_entity or "").strip().lower()
for unit in units:
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
if not entity or entity.lower() == skip:
continue
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
dev_min = getattr(unit, "dev_min", None)
dev_max = getattr(unit, "dev_max", None)
name = getattr(unit, "name", entity)
if dev_max is not None and cap > float(dev_max):
problems.append(
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
f"configured for this inverter would silently never happen. "
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
elif dev_min is not None and -cap < float(dev_min):
problems.append(
f"{name}: max_w is {cap:.0f} W, so this controller may command "
f"{-cap:.0f} W, but {entity} accepts no less than "
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
"less.")
return problems
class ConfigRefused(Exception):
"""The configuration is wrong. Not "something broke" - see main.py's handler.
@@ -367,7 +617,21 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"each actuator exactly one writer, or point the second one at "
"its own entity.")
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
# roster what one unit may be commanded, so a roster that is nonsense has to
# have been named as nonsense before they read it - otherwise an entry with
# no `max_w` silently contributes no cap, `min()` sees a shorter list, and
# the operator is told about a sign watchdog when the real problem is a
# missing rating one line up.
problems.extend(_roster_problems(opts))
# --- 2. the configured ceiling against the device's own rating -----------
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
# speak for the single-inverter shape. The per-unit form of it is
# `validate_unit_ranges()`, which main.py runs once every unit's own device
# has answered; it is not called from here because at startup no device has.
max_w = _number(opts, "max_w", 2000.0)
if max_w is None:
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
@@ -446,18 +710,37 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# below max_w, so threshold == max_w is already unsatisfiable. Anything
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
# case only, not "close to the rail".
#
# ⚠️ AND IT IS GRADED AGAINST THE SMALLEST UNIT'S CAP, NOT AGAINST THE FLAT
# `max_w`. There is one SignWatchdog PER INVERTER (DEV-02) and each grades
# its own unit's standing command, which the arbiter bounds by THAT UNIT's
# `supervised` claim - so the watchdog that can never fire is the one on the
# smallest inverter, and a threshold under the site's largest cap says
# nothing about it. At one inverter `unit_caps()` is `[max_w]` and this is
# the identical comparison it always was.
sign_w = _number(opts, "sign_disagree_w", 300.0)
# ⚠️ (index, cap) PAIRS, NOT BARE CAPS. `unit_caps()` skips entries with no
# usable `max_w`, so `caps.index(weakest)` was an index into the CAPS list
# being handed to `_unit_label()`, which indexes the ROSTER - and the message
# named the wrong inverter as soon as the two lengths differed. The index
# travels with the number now, so the name is the name of the unit the
# number came off.
caps_ix = unit_caps_indexed(opts)
caps = [cap for _, cap in caps_ix]
weakest = min(caps) if caps else None
if sign_w is None:
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and sign_w >= max_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"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
f"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
f"{weakest:.0f} W, so "
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
"arbiter never lets a command exceed max_w, and the watchdog needs "
f"a command over {sign_w:.0f} W. It would sit there reporting "
"'armed' on the status page while being incapable of tripping - the "
f"one runaway failure mode this system has actually observed. Lower "
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
"default), or raise max_w.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
@@ -518,22 +801,40 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# 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.
#
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
# every one of them; 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 - which is the over-refusal this file's
# docstring says gets a validator switched off. When `maint_unit` stops
# being `inverters[0]`, this line moves with it.
charge_w = _number(opts, "maintenance_charge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
# regardless. Both come off the one pair now.
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
elif max_w is not None and max_w > 0 and charge_w > max_w:
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
problems.append(
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
"never happen: the shaping asks the arbiter for at most "
f"{-charge_w:.0f} W while the supervised limit allows no less than "
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
f"{-maint_cap:.0f} W, which is a contradiction it resolves to 0 W - "
"THE CEILING WINS, and what you would see is an ARBITER "
"CONTRADICTION line that reads like a bug rather than like these "
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
f"or raise max_w to at least {charge_w:.0f} W.")
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
f"less, or raise max_w to at least {charge_w:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
@@ -593,18 +894,23 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# 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 max_w is not None and max_w > 0 and disch_w > max_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 %.0f W. Not refused - "
"the drain phase SETS the setpoint and the arbiter clamps it to "
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
"nothing is silent and the cycle still completes. But it drains at "
"%.0f W, not the %.0f W you configured, and the drain phase has a "
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
"refused - the drain phase SETS the setpoint and the arbiter clamps "
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
"so nothing is silent and the cycle still completes. But it drains "
"at %.0f W, not the %.0f W you configured, and the drain phase has "
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, max_w, max_w, max_w, disch_w, max_w)
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
maint_cap)
return problems