CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s

DEV-02 made every per-device value per-instance - rating, SoC, watchdogs,
heartbeat, store keys - so the code stopped assuming one inverter. The options
schema did not. `build_roster()` returned a list of exactly one and said so in
its own docstring, and there was no `inverters:` key for an operator to write,
so the roster could only ever be a roster of one.

THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN

config.yaml had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` precisely because a nested
schema was doubted. That is a question about somebody else's software, so it was
answered by running it: `tools/supervisor_shape_probe.py` (committed) runs
Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out
of the release image, against this repository's config.yaml. 2026.08.0 is the
version the live install reports. The full transcript and the argument are in
DECISIONS/CFG-04-inverter-roster.md.

It accepts the list, enforces per-entry types, refuses a missing `name` /
`setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a
repeatable section, and drops the key with a warning under an older schema - so
a downgrade is not the forward trap in reverse.

⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT
THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for
an options blob that has no such key - which every existing install's has, since
the key did not exist when they were written. `App.options` is the shipped
`options:` block deep-merged with the operator's stored one, so the empty
default is the difference between an upgrade nobody notices and an add-on that
cannot start with a schema pinned to the version that would fix it. That is
docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was
sprung. The merger was checked too: lists override rather than concatenate, so a
non-empty default could not have grown a roster behind the operator's back.

NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE

`name`, `setpoint_entity` and `max_w` are required of every entry. An entry that
omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A
three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a
card removing, in YAML. Defaults still live in exactly one place (CFG-03):
there is no per-entry default anywhere, because there is no optional per-entry
number. A flat `setpoint_entity` left set beside a declared roster is refused
rather than ignored - it names an actuator nothing will write.

TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS

  validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`.
  At a roster of two 2000 W units with `max_w: 5000` left over from a
  single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
  SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode,
  reached through rule 5. They read `unit_caps()` now, which at one inverter is
  `[max_w]` exactly.

  main.py's `run_control()` re-ran the device-rating check behind
  `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank
  - so on a two-inverter site no inverter's `max_w` was graded against the
  device it is written to, and the skip was silent. `validate_unit_ranges()`
  grades each unit against its own ESPHome and runs unconditionally.

  test_peakguard.py's `read_option()` and `_schema_bound()` and
  test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the
  first hit, so the nested per-inverter `max_w:` began outranking the flat one -
  silently, and correctly, because the two bounds are the same int(100,5000)
  today. A reader that is right by coincidence is this project's named failure
  mode. All three are anchored at exactly two spaces now; the file's two halves
  disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises
  rather than picking one. test_tariff.py's copy of the idiom went with them.

THE AUDIT IS A CHECK, NOT A PROMISE

test_inverter.py part 11 finds every config.yaml-reading function in the suite -
transitively, because two of the readers CFG-04 had to change never name the
file - EXECUTES each against a synthetic two-inverter config.yaml whose
per-inverter numbers differ from its flat ones, and fails if any answers with
the per-inverter value. The table is not maintained by hand: the signature
decides, so the eleventh reader is named on its first run. config.yaml declares
`inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so
the trap stays baited.

SAFETY-28's `actuator_writers()` needed no change - it was already a structural,
case-folding census - but until now there was no configuration in which two
writers on one actuator could be WRITTEN DOWN. There is now, and it is refused.

VERIFIED BY BREAKING IT

Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each. Two survived the first
run and the CHECKS were changed, not the table: the inheritance check had blank
flat keys, so "inherited" and "not inherited" were the same empty string; and
the `run_control()` check asserted a SOURCE LINE was present, which re-indenting
it does not remove. Both are recorded in MUTATION.md's "the check's data cannot
reach the failure it names" section, which now carries two more habits: assert
on behaviour rather than on source text, and give a check whose failure mode is
a hang a deadline.

19/19 green in the goodwe-addon-ci image. The one-inverter path is byte-
identical: the whole existing suite runs unmodified against it.

Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so
a schema change without a bump is a schema nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 04:29:45 +02:00
co-authored by Claude Opus 5
parent b30797e1f9
commit 6b85acb0f1
13 changed files with 1856 additions and 119 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 SignWatchdogConfig, loop_is_following
from . import web
@@ -177,6 +179,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)),
@@ -2386,37 +2406,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))
+294 -17
View File
@@ -150,6 +150,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
@@ -202,6 +228,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
@@ -307,6 +334,211 @@ 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.
"""
entries = roster_entries(opts)
if not entries:
cap = _number(opts, "max_w", 2000.0)
return [] if cap is None else [cap]
caps: list[float] = []
for entry in entries:
if not isinstance(entry, dict):
continue
cap = _number(entry, "max_w", None)
if cap is not None:
caps.append(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.
@@ -365,7 +597,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")
@@ -444,18 +690,30 @@ 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)
caps = unit_caps(opts)
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, caps.index(weakest))
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.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
@@ -484,22 +742,36 @@ 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"]))
maint_cap = caps[0] if caps else None
maint_where = _unit_label(opts, 0)
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))
@@ -559,18 +831,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