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
+8
View File
@@ -160,6 +160,14 @@ class Driver:
never a release: that is `write(0)`."""
return await self.write(0.0)
async def release_fallback(self) -> bool:
"""SMA-10 round 2. What to send, ONCE, when `release()` has failed
(False, raised, or hung past the bound), so the unit does not carry its
last command while nothing else steers it. Default: `write(0)`. It is
not repeated: a repeat would keep resetting the inverter's own fallback
timer. A driver for which 0 W is not always a safe hold overrides this."""
return await self.write(0.0)
async def probe(self) -> Probe:
"""A read-only look, usable without a Controller (panel, wizard)."""
raise NotImplementedError
+1 -1
View File
@@ -27,7 +27,7 @@ import re
from dataclasses import dataclass
_BACK_EN = "its own control"
_BACK_NL = "eigen sturing"
_BACK_NL = "de eigen sturing"
@dataclass(frozen=True)
+6
View File
@@ -284,6 +284,12 @@ class Inverter:
# SMA-10. Writes to a hand-back driver that are on their way (see
# Controller._write_unit), so the release can wait for them and be LAST.
self.inflight: set = set()
# SMA-10 round 2. A failed hand-back: how many tries in a row failed
# (the log is written at the first and on recovery only), when the next
# try is due (monotonic s), and whether the one fallback 0 W was sent.
self.release_fails = 0
self.release_retry_at = 0.0
self.release_zero_sent = False
self.last_write_ok = None
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
# verdict and it flips on a single transient 400; liveness needs a RUN
+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))
+1 -1
View File
@@ -1389,7 +1389,7 @@ HINTS = {
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"handed_back": "{unit}: handed back, the add-on writes nothing to it",
"hand_back_failed": "{unit}: could not be handed back, trying again",
"hand_back_failed": "{unit}: could not be handed back, set to 0 W once, trying again",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"readings_late": "Home Assistant readings arrive late, control holds",
"readings_back": "Home Assistant readings are on time again",