MAINT-21: comments only - trim restated notes, add DECISIONS pointers
checks / unit (pull_request) Successful in 25m17s
infra-pin / fresh (pull_request) Successful in 1s

No code change: tools/maint21_same_ast.py and tools/maint20_same_code.py HEAD~
both print OK, and the -OO code objects are the same.

- Trims 56 comment and docstring lines (2088 -> 2032) in app/main.py and the
  new files that only restated their MAIN-orchestration.md bullet, each cut to
  its first sentence and a pointer. What the cut comments said and the bullet
  did not is now in the bullet: why a SAFETY-02 latch is restored, why the
  SAFETY-30 site check is not armed at one inverter, the SAFETY-18 re-derive,
  the MAINT-05 one-config rule, the OPT-09 sizing rules, the "no plan" row,
  the 5 400 s age bound, and the guard being the only latch repair.
- Adds the missing DECISIONS pointers on the SMA-10, SAFETY-137 and SMA-2
  comments (FALLBACK_BUDGET_S, write_setpoint, _await_inflight,
  _release_last, the retry wait, the failed-release branch, release_zero_sent,
  the in-flight write, the stop_event hand-back, the SMA-2 read and driver).
- DECISIONS/SAFETY-32: the stale `main.py:932-936` now names
  Controller.pack_floor_soc.
- main.py's docstring and import block say where the Controller's parts are.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-09 00:41:34 +01:00
co-authored by Claude
parent 27a867f34b
commit 902da3f723
8 changed files with 87 additions and 141 deletions
+42 -75
View File
@@ -16,7 +16,10 @@ takes the battery to zero. Never "hold the last value to be safe".
layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only
cover for this add-on's host dying.
Per-section reasoning: DECISIONS/MAIN-orchestration.md."""
Per-section reasoning: DECISIONS/MAIN-orchestration.md. The Controller keeps
__init__, its roster views and shutdown here; its other methods are in
ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
ctl_tasks (MAINT-21), the status-row functions in rows."""
import asyncio
import contextlib
@@ -103,6 +106,8 @@ from .watchdog import (
)
from . import setuponly
from . import web
# MAINT-21: the Controller's parts and the names they now define; every one
# stays importable from `app.main`. DECISIONS/MAIN-orchestration.md.
from .ctl_tasks import TasksMixin
from .ctl_status import StatusMixin
from .ctl_alarms import (
@@ -190,42 +195,32 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self.store = store
self.mqtt = mqtt_pub
# REQUIRED and keyword-only: a default `stop=None` made a Controller
# that cannot stop itself (inert but looks healthy on the late-refusal
# path in run_control()). DECISIONS/MAIN-orchestration.md.
# that cannot stop itself. DECISIONS/MAIN-orchestration.md.
self.stop_event = stop
self.refused: list[str] | None = None
# First, because the store migration below can log to it: 15's action
# log is how an operator learns a latch was re-armed, and an
# AttributeError here would stop the add-on booting.
# First: the store migration below can log to it.
# DECISIONS/MAIN-orchestration.md.
self.events: list[str] = []
# UX-08: the last event in plain words, for the page. See log_event().
self.hint = ""
# DEV-02: the roster, built before anything reads a rating; every
# per-device value lives on these objects (app/inverter.py), but a
# `Tuning` and the integrator are deliberately NOT per device (6.2: one
# integrator on P1 above the split). The store migration runs here,
# BEFORE the first `Inverter` reads its SAFETY-02 latch, else a tripped
# install comes up ARMED with a green banner.
# DECISIONS/MAIN-orchestration.md.
# DEV-02: the roster, built before anything reads a rating. The store
# migration runs here, BEFORE the first `Inverter` reads its SAFETY-02
# latch. DECISIONS/MAIN-orchestration.md.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30: the per-unit comparison's own pair, re-derived at two
# inverters, not carried across (a command against one unit's own power
# is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md;
# watchdog.SIGN_OPTION_DEFAULTS.
# SAFETY-30: the per-unit pair, re-derived at two inverters, not
# carried. DECISIONS/SAFETY-30-per-unit-sign.md.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30: the site comparison's own pair, used only at N>=2. The 300
# W threshold is carried (meter noise and deadband); the window is not
# (110 s at one inverter, 215 s at two, against a 180 s hold). See
# watchdog.SIGN_OPTION_DEFAULTS.
# SAFETY-30: the site pair, used only at N>=2.
# DECISIONS/MAIN-orchestration.md.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
@@ -239,11 +234,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
# SAFETY-44: hand the migration the names the machines read,
# `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the
# latch landed under `unit/inverter-a/`, on no roster, and stamped the
# marker). `roster_names()` is `[UNIT_A]` on the flat shape, so today's
# path is byte-identical. DECISIONS/MAIN-orchestration.md.
# SAFETY-44: the migration gets `roster_names(opts)[0]`, never
# `UNIT_A`. DECISIONS/MAIN-orchestration.md.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
@@ -252,10 +244,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
# restart and shadow mode (DECISIONS/SMA-10-two-idle-states.md).
handback.set_roster(handback.units_of(self.inverters))
uitext.set_idle(handback.overlay(handback.current()))
# CFG-04: say which shape is in force. A declared `inverters:` roster
# ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which
# the UI still shows; validate.py rule 9(e) refuses only the dangerous
# one, so the rest are DISCLOSED in one action-log line at every start.
# CFG-04: a declared roster ignores flat keys the UI still shows; say
# so once at every start. DECISIONS/MAIN-orchestration.md.
if roster_entries(opts):
self.log_event(
"roster: %d inverters from `inverters:` - %s. The flat "
@@ -267,14 +257,11 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
# The SITE bound is summed from the roster, not `opts["max_w"]`: it
# is the integrator bound and rail of the site loop, split across
# inverters. Identical to `max_w` at N=1; a per-unit `max_w` still
# binds each share through its `supervised` claim.
# The SITE bound is summed from the roster, not `opts["max_w"]`.
# DECISIONS/MAIN-orchestration.md.
max_w=self.site_max_w,
# SAFETY-39: the fallback is 0, meaning "follow the rail"
# (config.yaml and `Tuning`). It was 1000, a stale third copy.
# `control.slew_limit_w()` is the one reader.
# SAFETY-39: 0 means "follow the rail"; `control.slew_limit_w()`
# is the one reader. DECISIONS/MAIN-orchestration.md.
slew_w=float(opts.get("slew_w", 0) or 0),
deadband_w=float(opts.get("deadband_w", 15)),
target_grid_w=float(opts.get("target_grid_w", 0)),
@@ -287,14 +274,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
integrator_max_w=(float(opts["integrator_max_w"])
if opts.get("integrator_max_w") else None),
)
# CFG-03: no `opts.get(key, <literal>)` for maintenance here;
# `MaintConfig.from_options()` reads them next to
# `maintenance.MAINT_OPTION_DEFAULTS`; a new key goes in config.yaml
# `options:`/`schema:` and that dict only. MAINT-05: one `Maintenance`
# per roster entry; the history migration runs first (else a pack
# believes it was never recalibrated) and targets
# `self.inverters[0].name`, NOT `UNIT_A`; the roster is passed so
# `migrate_maint_keys()` grades the namespace it wrote.
# CFG-03: `MaintConfig.from_options()` reads the options. MAINT-05:
# one `Maintenance` per roster entry; the history migration runs first,
# onto `self.inverters[0].name`, NOT `UNIT_A`.
# DECISIONS/MAIN-orchestration.md.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
@@ -306,12 +288,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self.log_event("maintenance history did not move over cleanly: "
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# MAINT-05: one config, N machines: the `maintenance_*` options
# describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY
# are per pack, in the store. A per-entry config block would be a
# second shape for CFG-04 and a fifth place for `maintenance_charge_w`
# to disagree with config.yaml; two procedures would be a roster-schema
# card.
# MAINT-05: one config, N machines: the options describe the
# PROCEDURE; the SCHEDULE and HISTORY are per pack, in the store.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
@@ -329,12 +307,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02: one watchdog per actuator, each restored from ITS OWN
# namespaced store keys (a trip on B latches B, A keeps commanding).
# Restored, because a latch held only in memory is cleared by the
# restart an operator tries first. Graded on every start: a
# disagreement with the flat value re-arms by hand and shouts (the
# fail-safe direction is ON). DECISIONS/MAIN-orchestration.md.
# SAFETY-02: one watchdog per actuator, restored from ITS OWN
# namespaced keys and graded on every start.
# DECISIONS/MAIN-orchestration.md.
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
@@ -354,12 +329,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
hint=messages.hint("stop_restored",
unit=inv.name))
# SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's
# and SAFETY-31's (which command 0 W). It is restored so the ALARM
# survives (2026-09-02: an outage unseen for 6 h 25 m with the detector
# correct); `delivery.py` argues reporting rather than acting.
# `_delivery_announced` is per process (as `_circ_announced`): a
# restart clears the flag and keeps the latch.
# SAFETY-42 + SAFETY-34: a latch that does NOT act, restored so the
# ALARM survives; `_delivery_announced` is per process, so a restart
# clears the flag and keeps the latch. DECISIONS/MAIN-orchestration.md.
self._delivery_announced = set()
for inv in self.inverters:
if inv.delivery_wd.tripped:
@@ -376,12 +348,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
hint=messages.hint("not_delivering",
unit=inv.name))
# SAFETY-30: the SITE half of the sign check, one per site, armed only
# at N>=2: the SUM of the standing commands against P1. At a roster of
# one it is constructed and never evaluated (arming it would duplicate
# `inverters[0].sign_wd`: two latches, two banners, a false-positive
# record spent twice); `cycle()` enforces it. Restored from the store
# with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md.
# SAFETY-30: the SITE half of the sign check: armed only at N>=2 (at
# one inverter it is constructed and never evaluated), restored with
# FLAT keys. DECISIONS/SAFETY-30-per-unit-sign.md.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
@@ -403,15 +372,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._arm_sign_watchdogs()
# SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT
# restored from the store: it does not act, so it does not latch; a
# restart re-derives it within one `meter_freeze_s`.
# restored: it does not act. DECISIONS/MAIN-orchestration.md.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a
# P_SAFETY SET that stops every inverter, and a cleared latch would
# restart straight into the loop it stopped. SITE-level keys, not
# namespaced: circulating power is defined over the roster (store.py).
# SAFETY-31: one per site, RESTORED because it acts, with SITE-level
# keys. DECISIONS/MAIN-orchestration.md.
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
@@ -1198,6 +1164,7 @@ async def amain() -> None:
# yet up is not a refusal (run_control() re-checks).
# (DECISIONS/MAIN-orchestration-archive.md)
# SMA-2: the flat options are the GoodWe driver's (no Controller yet).
# DECISIONS/SMA-2-driver-seam.md.
_sp = str(opts.get("setpoint_entity", "") or "").strip()
_drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass)
_dev_min = _dev_max = None