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
+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))