SMA-10: two idle states, hold 0 W or hand the inverter back
checks / unit (pull_request) Successful in 24m9s
infra-pin / fresh (pull_request) Successful in 1s

A driver now says whether its release() hands the inverter back
(Driver.hands_back; GoodWe is False, so release() stays write(0) and the
Controller never calls it). command_site_release() sits next to
command_site_zero(): the Stop button and shutdown() (so SIGTERM, restart,
control task death and a late config refusal) hand a hand-back unit back,
once, and zero every other unit as before. Safety stops and refusals still
hold 0 W for every unit. Shadow mode releases a hand-back unit. After a
release nothing is written to the unit (heartbeat and cycle included) until
control starts again. release() is bounded by RELEASE_BUDGET_S (= the write
budget), runs concurrently per unit, and a raising or hanging release on one
unit does not stop the others.

The "set to 0 W" sentences for Stop, restart, shadow mode and the stopped
banner are built from the roster (app/handback.py, EN and NL, words from the
driver); a GoodWe-only roster keeps every old literal. A handed-back unit
shows "Back under <its control>" on Home.

Existing test edited: test_messages.py (two classification entries for the
CRITICAL line that moved into a roster builder, and the new release bound).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-07 18:21:28 +01:00
co-authored by Claude
parent 57c8d1b912
commit a1be9c48b3
12 changed files with 1720 additions and 22 deletions
@@ -0,0 +1,51 @@
# SMA-10 - two idle states: hold 0 W, or hand the inverter back
**Status: for review.** Tests: `test_sma10_two_idle_states.py`. Source: SMA-9
report sections 1.4, 3.2; owner decision 2026-10-20 (Q1, "as proposed").
A unit is idle in one of two ways, and the driver says which:
`Driver.hands_back`. **False (GoodWe):** the add-on holds 0 W, `release()` is
`write(0)` and the Controller never calls it, so Stop keeps today's 0 W write and
today's 0 W heartbeat. **True:** `release()` gives the unit back to whatever
steers it by itself (SMA energy management), once, and then nothing is written
to it until control starts again.
**Why two.** When the add-on goes away (Stop, shutdown, SIGTERM, restart, task
death, a config refusal) nobody is left to hold a number, so the unit goes back
to its own controller. When the add-on itself decides to stop (sign trip, site
sign, circulating power, a refused or non-finite write, missing readings,
another writer, an inverter fault) it has just said it cannot trust that
battery, and handing it to something else would let that something do what it
likes with it. Those hold 0 W through `write(0)` for every unit, whatever its
driver. Shadow mode on a unit that hands back uses `release()`: it must leave
the unit alone, and 0 W would be a command.
**Where.** `Controller.command_site_release()` sits beside `command_site_zero()`
(unchanged). Stop and `shutdown()` call it; the two maintenance-abort paths keep
zero (the add-on runs on, the sentence says 0 W for a moment). With no
hand-back unit it is `command_site_zero()`. `_write_unit()` also refuses to write
a hand-back unit while the add-on is idle (stopping, stopped, or shadow mode): it
releases once and returns, so the heartbeat and the cycle's own "stopped" 0 W
cannot reach it. A safety stop runs while control runs, so it falls through to
the write. Start re-arms: `released` drops and `last_write` clears.
**Bounds.** `RELEASE_BUDGET_S` equals `WRITE_BUDGET_S` (4 s): units are released
together, so one slow unit costs the kill window no more than a hung GoodWe
write does. A release at the bound is abandoned, not awaited. A release that
raises, hangs or returns false leaves that unit un-handed (the next idle write
tries again) and does not touch the others. Exit 78 before the Controller
exists has no driver to release and keeps today's path.
**Words.** Every sentence that said "set to 0 W" for the add-on going away or
for shadow mode is built from the roster in `app/handback.py` (EN and NL): a
roster with no hand-back unit gets None and keeps its old literal, so a GoodWe
site sees no changed byte. A hand-back unit's words (`Driver.words`: `back`,
`back_nl`) come from its driver. MQTT `status` is unchanged; the Home battery
row of a handed-back unit says "Back under SMA control" and carries
`handed_back`.
**Not done here (SMA-4, SMA-5, SMA-11):** any SMA/FoxESS driver, the read-back
after `release()`, per-driver timing, DOCS.md's safety model and the
"control stalled" sentence, the site-level "Held at 0 W" tiles in shadow mode on
a mixed roster, and the MQTT per-unit setpoint of a handed-back unit (it still
publishes 0).