MAINT-20 part 2 r2: cut the comments that came in with SMA-2, SMA-10, SAFETY-128 and SAFETY-137

Word for word in MAIN-orchestration-archive.md, keyed by the line at 16029d7.
No code change.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-08 17:29:27 +01:00
co-authored by Claude
parent c2b4302dcf
commit 8fc5f15b03
2 changed files with 217 additions and 91 deletions
@@ -2583,3 +2583,182 @@ everything an operator needs, and a stack trace on top of it would say
"this crashed" - the exact confusion AC3 is about. 78 is sysexits(3)
EX_CONFIG, so even a script reading the exit status can tell a bad
config from a bug.
## Added at release/1.0 `16029d7` (SMA-2, SMA-10, SAFETY-128, SAFETY-137)
Same form, keyed by the first line number in `app/main.py` at `16029d7`.
### L143 SMA-10. The same bound for handing a unit back (`driver.release()`). I
SMA-10. The same bound for handing a unit back (`driver.release()`). It is the
time the shutdown path already allows one 0 W write, and the units are
released CONCURRENTLY, so one slow or hung unit costs the wind-down no more
than a hung GoodWe write does today: the Supervisor's kill window is not lost
to it. A release still running at the bound is abandoned, not awaited.
### L150 SMA-10 round 2. The one fallback write after a failed release (default
SMA-10 round 2. The one fallback write after a failed release (default 0 W,
`Driver.release_fallback()`). Worst case for a unit whose release hangs:
RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, against the Supervisor's 10 s.
### L156 SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a r
SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a release)
waits for a write already on its way to the same unit. Half the release
budget, as `_release_last` always waited. For a GoodWe unit that is
SETTLE_BUDGET_S + WRITE_BUDGET_S = 6 s at worst, which is HAND_BACK_WORST_S:
the wind-down is no slower than SMA-10's worst case.
### L586 SMA-10. Which units hand back decides what the page says about Stop,
SMA-10. Which units hand back decides what the page says about Stop,
restart and shadow mode. A GoodWe-only roster adds nothing.
### L3152 SAFETY-128: update the answer gate; seconds the late verdict has
SAFETY-128: update the answer gate; seconds the late verdict has
stood, or None. Readings found late are logged once per episode.
DECISIONS/SAFETY-128-late-readings.md.
### L3805 `inv`'s driver, wired to this Controller's Home Assistant on first use
`inv`'s driver, wired to this Controller's Home Assistant on first use.
SMA-2. Bound HERE rather than when the roster is built, so a unit added
to `self.inverters` afterwards is wired too, and the driver follows
whatever `self.hass` is at the time (a check may swap it).
### L3835 SMA-2. The driver reads, in the order this method always did: SoC,
SMA-2. The driver reads, in the order this method always did: SoC,
then power. `extra` is whatever else the driver learned; nothing
consumes it yet.
### L4074 Wait, at most `budget_s`, for the writes already on their way to
Wait, at most `budget_s`, for the writes already on their way to
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it.
### L4082 `release()`, after a write already on its way to `inv` has finished
`release()`, after a write already on its way to `inv` has finished
(at most half the budget is spent waiting for it), so the release is the
LAST thing the unit hears and a late write cannot undo it.
### L4089 SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
True when the unit is handed back (now, or already). Never raises: a
driver that raises or hangs is logged and the unit is tried again on the
next idle write, and it cannot stop a sibling being released or zeroed.
⚠️ `released` IS SET BEFORE THE FIRST AWAIT. A second caller (the
heartbeat while the Stop button is mid-release) then sees it and does
nothing, so a unit gets one `release()` and not two. If the release
did not take, the flag goes back down and the unit stays un-handed
until a later idle write tries again.
⚠️ IT IS NOT A WRITE: no `note_write`, no `last_write`, so a hand-back
cannot be read as a link that answered. `last_write` is cleared so the
first write after control starts again is always sent.
### L4109 After a failure the next try waits one heartbeat, so a hung release
After a failure the next try waits one heartbeat, so a hung release
costs a cycle its 4 s once per heartbeat, not on every cycle. The
Stop button and shutdown (`force`) always try.
### L4146 ⚠️ ONCE. The unit must not keep carrying its last command, but a
⚠️ ONCE. The unit must not keep carrying its last command, but a
0 W re-sent on every try would reset the inverter's own fallback
timer for ever. The driver says what the one write is.
### L4162 --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
--- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
IDLE. Idle is: shutting down, control stopped, or shadow mode. The
unit is released once and then nothing is written to it - not the
heartbeat, not the cycle's "stopped" 0 W - so its own controller keeps
it. This is the passive half; `command_site_release()` is the active
one, for the moment the add-on goes away.
⚠️ A SAFETY STOP IS NOT IDLE. It happens while control runs (`auto`,
not shadow), so it falls through to the 0 W write below: the add-on's
own decision to hold 0 W is never turned into a hand-back.
⚠️ AND CONTROL STARTING AGAIN RE-ARMS THE UNIT: the flag drops and
`last_write` is cleared, so the first write goes out whatever it is.
`stopping` never re-arms: a shutdown in progress stays handed back.
### L4181 Control has the unit again: forget the hand-back, and any
Control has the unit again: forget the hand-back, and any
failed streak with it, so the next Stop starts clean (its own
one 0 W, its own error line and event).
### L4302 --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
--- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
It first waits, bounded, for a write already on its way to this unit,
so that write cannot land after it. And once shutdown has begun every
other write (cycle, heartbeat, a safety stop) is dropped here: the
check sits right before the write is created with no await between,
so a write that starts after it cannot exist, and one that started
before is in `inflight` for the wait above. The idle gate for a
hand-back unit is higher up and does the same for those.
### L4328 SMA-10 / SAFETY-137: kept for every driver, so a release or a last
SMA-10 / SAFETY-137: kept for every driver, so a release or a last
0 W can wait for it (`_await_inflight`).
### L6948 Signal, then return WITHOUT entering the loop. amain() wakes
Signal, then return WITHOUT entering the loop. amain() wakes
on this, runs the normal wind-down (which commands 0 W, or hands
back a unit whose driver does: SMA-10) and re-raises
ConfigRefused afterwards, so the exit code is 78 and the
inverter is left at zero rather than latched.
### L7222 SMA-10. The add-on is going away (Stop, shutdown, restart, task
SMA-10. The add-on is going away (Stop, shutdown, restart, task
death, a config refusal): every unit that hands back is given back, and
every other unit is held at 0 W exactly as `command_site_zero()` does.
⚠️ THE SAFETY STOPS AND REFUSALS DO NOT COME HERE. A sign trip, a
refused write, a missing reading, another writer: those are the add-on's
own decisions and hold 0 W through `write(0)` for EVERY unit, handed-back
drivers included. `command_site_zero()` is that, and it is unchanged. A
unit the add-on has just declared it cannot trust must not be handed to
something else that will then do what it likes with the battery.
⚠️ A ROSTER WITH NO HAND-BACK UNIT IS `command_site_zero()`, line for
line: GoodWe's release is its 0 W write, so Stop keeps today's write
and today's heartbeat.
⚠️ ONE UNIT'S FAILURE IS ITS OWN. `_hand_back()` never raises and is
bounded by RELEASE_BUDGET_S; the units run concurrently, so the whole
call takes no longer than its slowest bound.
### L8207 SMA-10: the Stop button is the add-on going quiet, so a unit
SMA-10: the Stop button is the add-on going quiet, so a unit
that hands back is given back; every other unit is zeroed.
### L8254 SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wai
SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wait
for it to wind down. `asyncio.wait_for` waits for the cancelled task to
finish, so a driver that ignores its cancellation would hold the whole
shutdown; this abandons it. Raises `asyncio.TimeoutError` at the bound.
### L8434 SMA-2. The flat options are the one-inverter shape, so it is the
SMA-2. The flat options are the one-inverter shape, so it is the
GoodWe driver's, asked without a Controller (none exists yet).
+38 -91
View File
@@ -2379,8 +2379,7 @@ class Controller:
def _readings_late(self, standing_w):
"""SAFETY-128: update the answer gate; seconds the late verdict has
stood, or None. Readings found late are logged once per episode.
DECISIONS/SAFETY-128-late-readings.md."""
stood, or None. DECISIONS/SAFETY-128-late-readings.md."""
a = self.answer
if self._now_mono is None:
return None
@@ -2937,12 +2936,9 @@ class Controller:
self.inputs_bad_since = datetime.now(timezone.utc)
def _driver(self, inv):
"""`inv`'s driver, wired to this Controller's Home Assistant on first use.
SMA-2. Bound HERE rather than when the roster is built, so a unit added
to `self.inverters` afterwards is wired too, and the driver follows
whatever `self.hass` is at the time (a check may swap it).
"""
"""`inv`'s driver, wired to this Controller's Home Assistant on first use
(SMA-2: bound here, so a unit added later and a swapped `self.hass` are
followed). DECISIONS/SMA-2-driver-seam.md."""
drv = inv.driver
if not drv.bound():
drv.bind_getter(lambda: self.hass)
@@ -3099,44 +3095,30 @@ class Controller:
for inv in targets))
async def _await_inflight(self, inv, budget_s: float) -> None:
"""Wait, at most `budget_s`, for the writes already on their way to
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it."""
"""Wait, at most `budget_s`, for writes already on their way to `inv`; one
still running is left to its own WRITE_BUDGET_S. SAFETY-137."""
pending = [t for t in inv.inflight if not t.done()]
if pending:
await asyncio.wait(pending, timeout=budget_s)
async def _release_last(self, inv) -> bool:
"""`release()`, after a write already on its way to `inv` has finished
(at most half the budget is spent waiting for it), so the release is the
LAST thing the unit hears and a late write cannot undo it."""
"""`release()` once any write in flight to `inv` has finished (at most
SETTLE_BUDGET_S): the release is the LAST thing the unit hears."""
await self._await_inflight(inv, SETTLE_BUDGET_S)
return await self._driver(inv).release()
async def _hand_back(self, inv, *, force: bool = False) -> bool:
"""SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
True when the unit is handed back (now, or already). Never raises: a
driver that raises or hangs is logged and the unit is tried again on the
next idle write, and it cannot stop a sibling being released or zeroed.
⚠️ `released` IS SET BEFORE THE FIRST AWAIT. A second caller (the
heartbeat while the Stop button is mid-release) then sees it and does
nothing, so a unit gets one `release()` and not two. If the release
did not take, the flag goes back down and the unit stays un-handed
until a later idle write tries again.
⚠️ IT IS NOT A WRITE: no `note_write`, no `last_write`, so a hand-back
cannot be read as a link that answered. `last_write` is cleared so the
first write after control starts again is always sent.
"""
True when handed back. Never raises; a failure is retried on the next
idle write. ⚠️ `released` is set before the first await (one release per
unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`.
DECISIONS/SMA-10-two-idle-states.md."""
if inv.released:
return True
if not inv.driver.configured():
return False
# After a failure the next try waits one heartbeat, so a hung release
# costs a cycle its 4 s once per heartbeat, not on every cycle. The
# Stop button and shutdown (`force`) always try.
# After a failure the next try waits one heartbeat; Stop and shutdown
# (`force`) always try.
now_m = time.monotonic()
if not force and now_m < inv.release_retry_at:
return False
@@ -3171,9 +3153,8 @@ class Controller:
self.log_event(f"{inv.name}: hand-back failed",
hint=messages.hint("hand_back_failed", unit=inv.name))
if not inv.release_zero_sent:
# ⚠️ ONCE. The unit must not keep carrying its last command, but a
# 0 W re-sent on every try would reset the inverter's own fallback
# timer for ever. The driver says what the one write is.
# ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own
# fallback timer for ever.
inv.release_zero_sent = True
try:
if await _bounded(self._driver(inv).release_fallback(),
@@ -3187,28 +3168,18 @@ class Controller:
last: bool = False) -> None:
if not inv.driver.configured():
return
# --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
# IDLE. Idle is: shutting down, control stopped, or shadow mode. The
# unit is released once and then nothing is written to it - not the
# heartbeat, not the cycle's "stopped" 0 W - so its own controller keeps
# it. This is the passive half; `command_site_release()` is the active
# one, for the moment the add-on goes away.
#
# ⚠️ A SAFETY STOP IS NOT IDLE. It happens while control runs (`auto`,
# not shadow), so it falls through to the 0 W write below: the add-on's
# own decision to hold 0 W is never turned into a hand-back.
#
# ⚠️ AND CONTROL STARTING AGAIN RE-ARMS THE UNIT: the flag drops and
# `last_write` is cleared, so the first write goes out whatever it is.
# `stopping` never re-arms: a shutdown in progress stays handed back.
# SMA-10: a unit that hands back is released once and not written while
# the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is
# NOT idle: it falls through to the 0 W write. Control starting again
# re-arms the unit; `stopping` never does.
# DECISIONS/SMA-10-two-idle-states.md.
if inv.driver.hands_back:
if self.stopping or self.shadow or not self.auto:
await self._hand_back(inv)
return
if inv.released or inv.release_fails or inv.release_zero_sent:
# Control has the unit again: forget the hand-back, and any
# failed streak with it, so the next Stop starts clean (its own
# one 0 W, its own error line and event).
# Control has the unit again: forget the hand-back and any
# failed streak.
inv.released = False
inv.last_write = None
inv.release_fails = 0
@@ -3270,15 +3241,10 @@ class Controller:
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
# --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
# HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
# It first waits, bounded, for a write already on its way to this unit,
# so that write cannot land after it. And once shutdown has begun every
# other write (cycle, heartbeat, a safety stop) is dropped here: the
# check sits right before the write is created with no await between,
# so a write that starts after it cannot exist, and one that started
# before is in `inflight` for the wait above. The idle gate for a
# hand-back unit is higher up and does the same for those.
# SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a
# write already on its way; once `stopping`, every other write is dropped
# here with no await before the write is made.
# DECISIONS/SAFETY-137-last-write-is-zero.md.
if last:
await self._await_inflight(inv, SETTLE_BUDGET_S)
elif self.stopping:
@@ -3294,8 +3260,7 @@ class Controller:
# ACCEPTED write).
try:
_w = self._driver(inv).write(value)
# SMA-10 / SAFETY-137: kept for every driver, so a release or a last
# 0 W can wait for it (`_await_inflight`).
# SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for it.
_w = asyncio.ensure_future(_w)
inv.inflight.add(_w)
_w.add_done_callback(inv.inflight.discard)
@@ -4834,11 +4799,8 @@ class Controller:
self.refused = problems
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal, then return WITHOUT entering the loop. amain() wakes
# on this, runs the normal wind-down (which commands 0 W, or hands
# back a unit whose driver does: SMA-10) and re-raises
# ConfigRefused afterwards, so the exit code is 78 and the
# inverter is left at zero rather than latched.
# Signal and return WITHOUT entering the loop: amain() runs the
# wind-down (0 W, or a hand-back: SMA-10) and exits with code 78.
self.stop_event.set()
return
@@ -4966,25 +4928,12 @@ class Controller:
self.mqtt.close()
async def command_site_release(self) -> None:
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task
death, a config refusal): every unit that hands back is given back, and
every other unit is held at 0 W exactly as `command_site_zero()` does.
⚠️ THE SAFETY STOPS AND REFUSALS DO NOT COME HERE. A sign trip, a
refused write, a missing reading, another writer: those are the add-on's
own decisions and hold 0 W through `write(0)` for EVERY unit, handed-back
drivers included. `command_site_zero()` is that, and it is unchanged. A
unit the add-on has just declared it cannot trust must not be handed to
something else that will then do what it likes with the battery.
⚠️ A ROSTER WITH NO HAND-BACK UNIT IS `command_site_zero()`, line for
line: GoodWe's release is its 0 W write, so Stop keeps today's write
and today's heartbeat.
⚠️ ONE UNIT'S FAILURE IS ITS OWN. `_hand_back()` never raises and is
bounded by RELEASE_BUDGET_S; the units run concurrently, so the whole
call takes no longer than its slowest bound.
"""
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task death,
config refusal): units that hand back are given back, the rest held at
0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT
come here: they hold 0 W for EVERY unit. A roster with no hand-back unit
is `command_site_zero()` line for line.
DECISIONS/SMA-10-two-idle-states.md."""
if not any(inv.driver.hands_back for inv in self.inverters):
await self.command_site_zero(last=True)
return
@@ -5722,10 +5671,8 @@ EXIT_CONTROL_DIED = 70
async def _bounded(awaitable, budget_s: float):
"""SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wait
for it to wind down. `asyncio.wait_for` waits for the cancelled task to
finish, so a driver that ignores its cancellation would hold the whole
shutdown; this abandons it. Raises `asyncio.TimeoutError` at the bound."""
"""SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting
for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`."""
task = asyncio.ensure_future(awaitable)
done, _ = await asyncio.wait({task}, timeout=budget_s)
if not done: