SMA-10 round 2: a failed release sends 0 W once, retries at heartbeat pace
checks / unit (pull_request) Successful in 23m37s
infra-pin / fresh (pull_request) Successful in 2s

When release() fails (False, raises or hangs past RELEASE_BUDGET_S) the unit
gets one bounded write(0) through the new Driver.release_fallback() hook
(default write(0)), then nothing more, so the inverter's own fallback timer is
not reset. The release is retried once per heartbeat, not on every idle
write. One error line and one action-log event per failure streak, one line on
recovery. Dutch default "terug naar de eigen sturing". DECISIONS says what a
unit is left at.

test_messages.py: the RELEASE_BUDGET_S data entry is gone again (the line
that needed it is gone).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 20:17:01 +01:00
co-authored by Claude
parent a1be9c48b3
commit d26cc573fe
8 changed files with 205 additions and 21 deletions
+44 -11
View File
@@ -147,6 +147,12 @@ WRITE_BUDGET_S = 4.0
# to it. A release still running at the bound is abandoned, not awaited.
RELEASE_BUDGET_S = WRITE_BUDGET_S
# 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.
FALLBACK_BUDGET_S = 2.0
HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
@@ -4065,7 +4071,7 @@ class Controller:
await asyncio.wait(pending, timeout=RELEASE_BUDGET_S / 2)
return await self._driver(inv).release()
async def _hand_back(self, inv) -> bool:
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
@@ -4086,27 +4092,54 @@ class Controller:
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.
now_m = time.monotonic()
if not force and now_m < inv.release_retry_at:
return False
inv.released = True
why = ""
try:
ok = bool(await _bounded(self._release_last(inv),
RELEASE_BUDGET_S))
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("Handing back %s took over %.0f s and was dropped. It "
"is tried again.", inv.name, RELEASE_BUDGET_S)
ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
except Exception as err: # noqa: BLE001 - one unit never stops the rest
ok = False
_LOG.error("Handing back %s failed: %s. It is tried again.",
inv.name, err)
ok, why = False, f"{type(err).__name__}: {err}"
if ok:
inv.last_write = None
if inv.release_fails:
_LOG.info("Handing back %s worked again after %d failed "
"tries.", inv.name, inv.release_fails)
inv.release_fails = 0
inv.release_retry_at = 0.0
inv.release_zero_sent = False
self.log_event(f"{inv.name}: handed back",
hint=messages.hint("handed_back", unit=inv.name))
else:
inv.released = False
return True
inv.released = False
inv.release_fails += 1
inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10))
_LOG.debug("Handing back %s failed (try %d): %s", inv.name,
inv.release_fails, why)
if inv.release_fails == 1: # one line per failure streak
_LOG.error("Handing back %s failed, so it is sent 0 W once and "
"handing back is tried again.", inv.name)
self.log_event(f"{inv.name}: hand-back failed",
hint=messages.hint("hand_back_failed", unit=inv.name))
return ok
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.
inv.release_zero_sent = True
try:
if await _bounded(self._driver(inv).release_fallback(),
FALLBACK_BUDGET_S):
inv.last_write = 0.0
except Exception as err: # noqa: BLE001
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
return False
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
if not inv.driver.configured():
@@ -7176,7 +7209,7 @@ class Controller:
for inv in self.inverters:
inv.target = 0.0
await asyncio.gather(*(
self._hand_back(inv) if inv.driver.hands_back
self._hand_back(inv, force=True) if inv.driver.hands_back
else self._write_unit(inv, 0.0, force=True)
for inv in self.inverters))