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
+16 -3
View File
@@ -77,7 +77,13 @@ class Driver:
writer_key = ""
#: the brand word the per-driver sentences and device names use
brand = ""
#: per-driver sentences (SMA-10 and later). Empty until a caller needs one.
#: SMA-10. Does `release()` give the inverter BACK to something that steers
#: it by itself (SMA energy management)? False: `release()` is `write(0)`,
#: the add-on goes on holding 0 W, and the Controller never calls it.
hands_back = False
#: per-driver sentences. A driver that hands back says whose control the
#: unit returns to: `{"back": "SMA control", "back_nl": "de sturing van SMA"}`
#: ("goes back to SMA control"). Missing words fall back to a plain phrase.
words: dict = {}
#: how long all-rejected writes last before the unit stops being live
link_stale_s = GOODWE_LINK_STALE_S
@@ -145,7 +151,13 @@ class Driver:
raise NotImplementedError
async def release(self) -> bool:
"""Stop steering this inverter. For a driver with nothing better, 0 W."""
"""Stop steering this inverter. For a driver with nothing better, 0 W.
SMA-10. Only a driver with `hands_back = True` has this called by the
Controller, and only when the add-on goes away (Stop, shutdown, restart,
task death) or sits in shadow mode. After it the Controller writes
nothing more to the unit until control starts again. A safety stop is
never a release: that is `write(0)`."""
return await self.write(0.0)
async def probe(self) -> Probe:
@@ -159,7 +171,8 @@ class GoodWeEsphomeDriver(Driver):
A transcription of what `main.py` and `selfconfig.py` did inline: `limits()`
is `hass.limits(setpoint)`, `read()` is `hass.number(soc)` then
`hass.number(batt, invert)`, `write()` is `hass.set_number(setpoint, w)`.
`release()` is `write(0)`: the board's own watchdog is what hands back.
`release()` is `write(0)` and `hands_back` is False: the board's own watchdog
is what hands back, so after Stop the unit keeps its 0 W write and heartbeat.
"""
name = DEFAULT_DRIVER
+386
View File
@@ -0,0 +1,386 @@
"""SMA-10: the two idle states, in words.
A unit is either HELD at 0 W (GoodWe: the add-on goes on writing 0 W) or
HANDED BACK (its driver has `hands_back = True`: `release()` gives it back to
whatever steers it by itself, and the add-on then writes nothing to it). So every
sentence that said "every inverter is set to 0 W" for the add-on going away or
for shadow mode has to be built from the roster. DECISIONS/SMA-10-two-idle-
states.md.
THE RULE FOR EVERY BUILDER HERE: a roster with no hand-back unit gets None, and
the caller then uses the sentence it always used, untouched. So a GoodWe-only
site cannot see a changed byte; test_sma10_two_idle_states.py renders each one
and compares it with the literal from before this card.
The words for a hand-back unit come from its driver (`Driver.words`), not from
here: `back` is "SMA control" in "goes back to SMA control", `back_nl` is the
Dutch noun phrase after "terug naar". A driver without them gets a plain,
brand-free phrase.
Safety stops and refusals are not in this file on purpose: they hold 0 W for
every unit, and their sentences say so.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
_BACK_EN = "its own control"
_BACK_NL = "eigen sturing"
@dataclass(frozen=True)
class Unit:
name: str
hands_back: bool = False
back_en: str = _BACK_EN
back_nl: str = _BACK_NL
def unit_of(inv) -> Unit:
"""One roster entry's view: its name, and (for a hand-back driver) the
words of whose control it returns to."""
drv = inv.driver
back = bool(getattr(drv, "hands_back", False))
words = getattr(drv, "words", None) or {}
return Unit(str(inv.name), back,
str(words.get("back") or _BACK_EN),
str(words.get("back_nl") or _BACK_NL))
def units_of(inverters) -> tuple:
return tuple(unit_of(i) for i in inverters)
# The roster of the Controller that is running, for the callers that have no
# Controller in hand (the page, the banner). One process runs one Controller.
_CURRENT: list = [()]
def set_roster(units) -> None:
_CURRENT[0] = tuple(units)
def current() -> tuple:
return _CURRENT[0]
def active(units) -> bool:
"""Is any unit handed back? False is every GoodWe-only roster."""
return any(u.hands_back for u in units)
# --------------------------------------------------------------------------- #
# The grammar. English and Dutch, a few verb forms each.
# --------------------------------------------------------------------------- #
def _join(names, lang) -> str:
names = list(names)
word = " en " if lang == "nl" else " and "
if len(names) <= 1:
return "".join(names)
return ", ".join(names[:-1]) + word + names[-1]
def _forms(lang):
"""kind -> (zero template, back template); `{n}` the names, `{b}` the words.
`one`/`many` pick the verb by how many units the clause is about."""
if lang == "nl":
return {
"set": (("{n} gaat naar 0 W", "{n} gaan naar 0 W"),
("{n} gaat terug naar {b}", "{n} gaan terug naar {b}")),
"at": (("{n} op 0 W",) * 2, ("{n} onder {b}",) * 2),
"is": (("{n} staat op 0 W", "{n} staan op 0 W"),
("{n} staat onder {b}", "{n} staan onder {b}")),
"stays": (("{n} blijft op 0 W", "{n} blijven op 0 W"),
("{n} blijft onder {b}", "{n} blijven onder {b}")),
}
return {
"set": (("{n} is set to 0 W", "{n} are set to 0 W"),
("{n} goes back to {b}", "{n} go back to {b}")),
"at": (("{n} at 0 W",) * 2, ("{n} under {b}",) * 2),
"is": (("{n} is at 0 W", "{n} are at 0 W"),
("{n} is under {b}", "{n} are under {b}")),
"stays": (("{n} stays at 0 W", "{n} stay at 0 W"),
("{n} stays under {b}", "{n} stay under {b}")),
}
def _groups(units, lang):
"""`(zero names, [(names, back words)])`: the held units, and the handed-back
ones grouped by whose control they return to, in roster order."""
zeros = [u.name for u in units if not u.hands_back]
groups: dict = {}
for u in units:
if u.hands_back:
groups.setdefault(u.back_nl if lang == "nl" else u.back_en,
[]).append(u.name)
return zeros, [(names, back) for back, names in groups.items()]
# A clause may not eat the sentence it sits in: STYLE.md allows 25 words, and
# the longest tail after a clause is about 11.
CLAUSE_MAX_WORDS = 14
def _count_txt(n, lang) -> str:
if lang == "nl":
return f"{n} omvormer" if n == 1 else f"{n} omvormers"
return f"{n} inverter" if n == 1 else f"{n} inverters"
def _clause(units, lang, kind, sep, generic, counted) -> str:
forms = _forms(lang)[kind]
zeros, groups = _groups(units, lang)
if generic: # one group, and no make named
names = [n for g, _b in groups for n in g]
own = ("de eigen sturing" if lang == "nl" else
"their own control" if len(names) > 1 else "its own control")
groups = [(names, own)] if names else []
subject = ("de omvormers", "de omvormer") if lang == "nl" else (
"the inverters", "the inverter")
parts = []
if zeros:
parts.append(forms[0][1 if len(zeros) > 1 else 0].format(
n=(str(len(zeros)) if groups else _count_txt(len(zeros), lang))
if counted else _join(zeros, lang)))
for names, back in groups:
if not zeros and len(groups) == 1:
names_txt = subject[0 if len(names) > 1 else 1]
elif counted:
# the bare number after a counted first clause: "1 inverter is set
# to 0 W and 2 go back to their own control"
names_txt = (str(len(names)) if zeros
else _count_txt(len(names), lang))
else:
names_txt = _join(names, lang)
parts.append(forms[1][1 if len(names) > 1 else 0].format(
n=names_txt, b=back))
if sep:
return sep.join(parts)
word = " en " if lang == "nl" else " and "
return (", ".join(parts[:-1]) + word + parts[-1]) if len(parts) > 1 \
else "".join(parts)
def clause(units, lang, kind, sep=None) -> str:
"""One clause about the whole roster, `kind` one of set/at/is/stays.
Held units first, then the handed-back ones, joined by "and" (or `sep`).
When EVERY unit is handed back to the same control the subject is "the
inverters", not their names, as the card's table has it.
A long roster would break the 25-word rule, so a clause over
CLAUSE_MAX_WORDS is said again without naming the make (one "own control"
for all), and then by counting units instead of naming them."""
text = _clause(units, lang, kind, sep, False, False)
if sep or _words(text) <= CLAUSE_MAX_WORDS:
return text
text = _clause(units, lang, kind, sep, True, False)
if _words(text) <= CLAUSE_MAX_WORDS:
return text
return _clause(units, lang, kind, sep, True, True)
def _cap(text) -> str:
return text[:1].upper() + text[1:]
def _bi(en, nl):
from .messages import Bi
return Bi(en, nl)
def _words(text) -> int:
return len([w for w in re.findall(r"\S+", text)
if re.search(r"[A-Za-z0-9]", w)])
# --------------------------------------------------------------------------- #
# The sentences. Each returns None for a roster with no hand-back unit.
# --------------------------------------------------------------------------- #
def stop_dialog(units, lang):
"""Stop control? (was "Every inverter is set to 0 W until you start
control again.")"""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} tot u de regeling opnieuw start." if lang == "nl"
else f"{_cap(c)} until you start control again.")
def restart_dialog(units, lang):
"""Restart now? / Save and restart? (live)"""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} terwijl de add-on herstart, daarna gelden de nieuwe "
f"instellingen." if lang == "nl" else
f"{_cap(c)} while the add-on restarts, and then the new settings "
f"apply.")
def restart_dialog_shadow(units, lang):
"""Save and restart? when shadow mode keeps the units idle afterwards."""
if not active(units):
return None
c = clause(units, lang, "set")
return (f"{_cap(c)} terwijl de add-on herstart, en de schaduwmodus houdt "
f"het zo." if lang == "nl" else
f"{_cap(c)} while the add-on restarts, and shadow mode keeps it "
f"that way.")
def setup_dialog_shadow(units, lang):
"""Save and restart? in setup, shadow mode on."""
if not active(units):
return None
c = clause(units, lang, "at")
return (f"De add-on herstart met deze instellingen, en de schaduwmodus "
f"houdt {c}." if lang == "nl" else
f"The add-on restarts with these settings, and shadow mode keeps "
f"{c}.")
def control_stopped(units, lang):
"""The page's control line when stopped."""
if not active(units):
return None
c = clause(units, lang, "is")
return (f"De regeling is gestopt. {_cap(c)}." if lang == "nl"
else f"Control is stopped. {_cap(c)}.")
def control_shadow(units, lang):
"""The page's control line in shadow mode."""
if not active(units):
return None
c = clause(units, lang, "stays")
return (f"Schaduwmodus: {c}." if lang == "nl" else f"Shadow mode: {c}.")
def attn_shadow_body(units, lang):
"""The attention card's body in shadow mode."""
if not active(units):
return None
c = clause(units, lang, "stays")
return (f"De add-on beslist alles, maar {c}." if lang == "nl" else
f"The add-on decides everything, but {c}.")
def shadow_state(units, lang, many=True):
"""The tail of a notification: "Shadow mode is on, so <this>." For the
three output-clamp sentences that said every inverter stays at 0 W."""
if not active(units):
return None
return clause(units, lang, "stays")
def banner_stopped(units):
"""Stopped. 12 words at most (STYLE.md)."""
if not active(units):
return None
out = {}
for lang, stopped in (("en", "Stopped"), ("nl", "Gestopt")):
text = f"{stopped} — {clause(units, lang, 'at', sep=', ')}"
if _words(text) > 12:
text = _banner_counts(units, lang, stopped)
out[lang] = text
return _bi(out["en"], out["nl"])
def _banner_counts(units, lang, stopped) -> str:
"""The long-roster form: how many, not which."""
zeros, groups = _groups(units, lang)
if len(groups) == 1:
back = groups[0][1]
n_back = len(groups[0][0])
if lang == "nl":
held = f"{len(zeros)} op 0 W, " if zeros else ""
return f"{stopped} — {held}{n_back} onder {back}"
held = f"{len(zeros)} at 0 W, " if zeros else ""
return f"{stopped} — {held}{n_back} under {back}"
return (f"{stopped} — omvormers op 0 W of teruggegeven" if lang == "nl"
else f"{stopped} — inverters at 0 W or handed back")
def banner_shadow(units):
"""Shadow mode, nothing standing. The GoodWe sentence adds "Inverters stay
at 0 W." which is false for a unit that hands back, and a banner is 12
words, so the unit-by-unit state is left to the control line."""
if not active(units):
return None
return _bi("Shadow mode: the battery is not controlled.",
"Schaduwmodus: de batterij wordt niet geregeld.")
def hint_shadow_on(units):
""""Shadow mode on, inverters stay at 0 W" for the Last event line."""
if not active(units):
return None
return "Shadow mode on, " + clause(units, "en", "at", sep=", ")
def unit_state(unit, lang):
"""One handed-back unit's own state, in place of "Held at 0 W"."""
if lang == "nl":
return f"Terug onder {unit.back_nl}"
return f"Back under {unit.back_en}"
def log_shutdown(units):
"""The INFO line at shutdown (was "shutting down - commanding 0 W on every
inverter")."""
if not active(units):
return None
zeros, groups = _groups(units, "en")
held = f"commanding 0 W on {_join(zeros, 'en')}" if zeros else ""
back = " and ".join(f"handing back {_join(n, 'en')} to {b}"
for n, b in groups)
return "shutting down - " + " and ".join(x for x in (held, back) if x)
def task_died_state(units) -> str:
"""The part of the CRITICAL line after "so" when the control task dies.
For a roster with no hand-back unit it is the sentence it always was."""
if not active(units):
return "every inverter is set to 0 W"
zeros, groups = _groups(units, "en")
backs = [n for names, _b in groups for n in names]
parts = []
if zeros:
parts.append(f"{_join(zeros, 'en')} "
f"{'are' if len(zeros) > 1 else 'is'} set to 0 W")
parts.append(f"{_join(backs, 'en')} "
f"{'are' if len(backs) > 1 else 'is'} handed back")
return " and ".join(parts)
# --------------------------------------------------------------------------- #
# The page words: the ui_text sentences that move with the roster, by language.
# --------------------------------------------------------------------------- #
def overlay(units) -> dict:
"""`{lang: nested dict}` of ui_text paths to replace; {} when no unit is
handed back, so the page is the file as it always was."""
if not active(units):
return {}
out = {}
for lang in ("en", "nl"):
out[lang] = {
"home": {"dialog": {"stop": {
"description": stop_dialog(units, lang)}}},
"settings": {
"dialog": {"description": restart_dialog(units, lang)},
"setup": {
"dialog": {"description":
restart_dialog_shadow(units, lang)},
"dialog_live": {"description":
restart_dialog(units, lang)},
"dialog_setup": {"description":
setup_dialog_shadow(units, lang)}}},
"home_words": {
"attn_shadow_body": attn_shadow_body(units, lang),
"control": {"shadow": control_shadow(units, lang),
"stopped": control_stopped(units, lang)}},
}
return out
+11 -1
View File
@@ -19,7 +19,7 @@ section at the bottom and is held to STYLE.md by test_messages.py.
import contextlib
from datetime import datetime, timedelta, timezone
from . import arbitrage, cheaproll, messages, peakguard, priceruns, uitext
from . import arbitrage, cheaproll, handback, messages, peakguard, priceruns, uitext
from .dayplan import EFF
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
@@ -936,6 +936,13 @@ def battery_rows(c, shadow, running, held=False):
else other, at=at,
**plan_words(c, inv.name, why)),
})
if getattr(inv, "released", False):
# SMA-10. A unit handed back says so, in its driver's words, not
# "Held at 0 W": nothing is written to it. The key exists only for
# such a unit, so a GoodWe-only page is unchanged.
rows[-1]["now"] = handback.unit_state(handback.unit_of(inv),
_LANG[0])
rows[-1]["handed_back"] = True
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
# screen. Only with battery modes on, so the page is unchanged
@@ -1211,6 +1218,9 @@ _WORDS: dict = {}
def _hw() -> dict:
lang = _LANG[0]
if _WORDS.get("") != uitext.generation(): # SMA-10: the roster's words moved
_WORDS.clear()
_WORDS[""] = uitext.generation()
if lang not in _WORDS:
_WORDS[lang] = uitext.words(lang)
return _WORDS[lang]
+9
View File
@@ -275,6 +275,15 @@ class Inverter:
self.target = 0.0
self.last_write = None
self.last_write_at = None
# SMA-10. True from the moment this unit is handed back (`release()`)
# until control starts again. While it is True nothing is written to the
# unit - not the heartbeat, not the cycle - so the unit's own
# controller keeps what it was given. Only a driver with
# `hands_back = True` ever sets it.
self.released = False
# 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()
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
+154 -11
View File
@@ -67,6 +67,7 @@ from . import freshness
from . import lateness
from . import dayview, homeview
from .daycurve import SolarToday
from . import handback
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
@@ -89,6 +90,7 @@ from . import peakguard
from . import packshare
from . import pv as pv_read
from . import shadow
from . import uitext
from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
@@ -138,6 +140,13 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
# the re-issue happens by itself) than hold the control loop for ten seconds.
WRITE_BUDGET_S = 4.0
# 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.
RELEASE_BUDGET_S = WRITE_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.
@@ -561,6 +570,10 @@ class Controller:
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# SMA-10. Which units hand back decides what the page says about Stop,
# restart and shadow mode. A GoodWe-only roster adds nothing.
handback.set_roster(handback.units_of(self.inverters))
uitext.set_idle(handback.overlay(handback.current()))
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
@@ -4043,9 +4056,82 @@ class Controller:
await asyncio.gather(*(self._write_unit(inv, value, force=force)
for inv in targets))
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."""
pending = [t for t in inv.inflight if not t.done()]
if pending:
await asyncio.wait(pending, timeout=RELEASE_BUDGET_S / 2)
return await self._driver(inv).release()
async def _hand_back(self, inv) -> 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.
"""
if inv.released:
return True
if not inv.driver.configured():
return False
inv.released = True
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)
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)
if ok:
inv.last_write = None
self.log_event(f"{inv.name}: handed back",
hint=messages.hint("handed_back", unit=inv.name))
else:
inv.released = False
self.log_event(f"{inv.name}: hand-back failed",
hint=messages.hint("hand_back_failed", unit=inv.name))
return ok
async def _write_unit(self, inv, value: float, *, force: 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.
if inv.driver.hands_back:
if self.stopping or self.shadow or not self.auto:
await self._hand_back(inv)
return
if inv.released:
inv.released = False
inv.last_write = None
entity = inv.driver.actuator_id() # for the log line below
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
@@ -4169,8 +4255,13 @@ class Controller:
# tick by itself: `last_write` is updated only on an ACCEPTED write, so
# `changed` is still true.
try:
ok = await asyncio.wait_for(self._driver(inv).write(value),
timeout=WRITE_BUDGET_S)
_w = self._driver(inv).write(value)
if inv.driver.hands_back:
# SMA-10: kept, so a release can wait for it (`_release_last`)
_w = asyncio.ensure_future(_w)
inv.inflight.add(_w)
_w.add_done_callback(inv.inflight.discard)
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
@@ -6786,9 +6877,10 @@ class Controller:
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) and
# re-raises ConfigRefused afterwards, so the exit code is 78 and
# the inverter is left at zero rather than latched.
# 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.
self.stop_event.set()
return
@@ -7052,10 +7144,42 @@ class Controller:
async def shutdown(self) -> None:
"""Deterministic wind-down. Do not skip this."""
self.stopping = True
_LOG.info("shutting down - commanding 0 W on every inverter")
await self.command_site_zero()
_LOG.info(handback.log_shutdown(handback.current())
or "shutting down - commanding 0 W on every inverter")
await self.command_site_release()
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.
"""
if not any(inv.driver.hands_back for inv in self.inverters):
await self.command_site_zero()
return
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await asyncio.gather(*(
self._hand_back(inv) if inv.driver.hands_back
else self._write_unit(inv, 0.0, force=True)
for inv in self.inverters))
async def command_site_zero(self) -> None:
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
@@ -8011,7 +8135,9 @@ class Controller:
# Zeroing `self.target` alone would leave each unit's last share
# standing for its next heartbeat to re-emit, which an operator
# would read as the stop button not working.
await self.command_site_zero()
# 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.
await self.command_site_release()
return {"auto": self.auto}
if what in ("maint_start", "maint_abort") and unit is not None:
# MAINT-11: the operator named the battery. A request with no unit
@@ -8055,6 +8181,21 @@ MAINT_CHARGING_W = 50.0
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."""
task = asyncio.ensure_future(awaitable)
done, _ = await asyncio.wait({task}, timeout=budget_s)
if not done:
task.cancel()
# a result or error that arrives later has nobody to read it
task.add_done_callback(lambda t: t.cancelled() or t.exception())
raise asyncio.TimeoutError
return task.result()
class ControlTaskDied(RuntimeError):
"""The control task ended while nothing had asked it to stop."""
@@ -8081,10 +8222,12 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("Control stopped unexpectedly (%s), so every inverter is "
"set to 0 W and the add-on exits with code %d.",
_LOG.critical("Control stopped unexpectedly (%s), so %s and the add-on "
"exits with code %d.",
"cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
(repr(exc) if exc else "returned"),
handback.task_died_state(handback.current()),
EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
stop.set()
+30 -3
View File
@@ -12,6 +12,8 @@ import re
from dataclasses import dataclass
from datetime import datetime
from . import handback
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one.
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
@@ -228,6 +230,19 @@ class Clamp:
limit: str # a key of LIMIT_WORDS
def _shadow_so(held: str) -> str:
""""Shadow mode is on, so <held>." SMA-10: with a unit handed back, the
roster's own words say where each unit stays; otherwise `held`, as ever."""
moved = handback.shadow_state(handback.current(), "en")
return f"Shadow mode is on, so {moved or held}."
def _shadow_keeps(held: str) -> str:
moved = handback.clause(handback.current(), "en", "at") \
if handback.active(handback.current()) else None
return moved or held
def output_clamp(clamps, *, shadow: bool = False) -> Notice:
"""SAFETY-03: a computed command above the limit. A software fault.
@@ -238,7 +253,7 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
fault = "This is a software fault, so please report it to your installer."
if not clamps:
cut = ("would have been cut back" if shadow else "was cut back")
state = ("Shadow mode is on, so every inverter stays at 0 W." if shadow
state = ((_shadow_so("every inverter stays at 0 W")) if shadow
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"The add-on asked for more power than {RATING} allows "
@@ -250,11 +265,11 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
if others:
verb = "was" if len(others) == 1 else "were"
state = (f"{names(others)} would have been cut too, and shadow mode "
f"keeps every inverter at 0 W." if shadow else
f"keeps {_shadow_keeps('every inverter at 0 W')}." if shadow else
f"{names(others)} {verb} cut too, and control is still "
f"running.")
else:
state = ("Shadow mode is on, so the inverter stays at 0 W." if shadow
state = (_shadow_so("the inverter stays at 0 W") if shadow
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"{who} add-on asked for {watts(c.asked_w)}, above "
@@ -576,6 +591,9 @@ def banner_running() -> str:
def banner_stopped() -> str:
moved = handback.banner_stopped(handback.current())
if moved is not None: # SMA-10: a unit is handed back, not held
return moved
return Bi("Stopped — the inverters are set to 0 W",
"Gestopt — de omvormers staan op 0 W")
@@ -601,6 +619,9 @@ def banner_shadow(latched: bool) -> str:
mode. Each stop has its own red row below, which names it and says how it
clears.
"""
moved = None if latched else handback.banner_shadow(handback.current())
if moved is not None: # SMA-10: a unit is handed back, not held
return moved
if latched:
return Bi("Shadow mode: battery not controlled. A safety stop stays "
"on, see below.",
@@ -1367,6 +1388,8 @@ HINTS = {
"setup_only": "In setup, nothing is sent to any inverter",
"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",
"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",
@@ -1470,6 +1493,10 @@ def hint(key: str, **kw) -> str:
kw["w"] = watts(kw["w"])
if "sw" in kw:
kw["sw"] = signed_watts(kw["sw"])
if key == "shadow_on": # SMA-10: a unit handed back is not at 0 W
moved = handback.hint_shadow_on(handback.current())
if moved is not None:
return moved
return HINTS[key].format(**kw)
+29 -2
View File
@@ -13,6 +13,11 @@ import os
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
LANGS = ("en", "nl")
_CACHE: dict = {}
# SMA-10. The sentences that move with the roster (handback.overlay): empty for
# every GoodWe-only roster, and then `raw()` is the file as it always was.
_IDLE: dict = {}
_GEN = [0]
_MERGED: dict = {}
def lang_of(value) -> str:
@@ -21,14 +26,36 @@ def lang_of(value) -> str:
return head if head in LANGS else "en"
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback."""
def _file(lang: str) -> dict:
if lang not in _CACHE:
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
_CACHE[lang] = json.load(f)
return _CACHE[lang]
def set_idle(overlay: dict) -> None:
"""SMA-10: the roster-built sentences, `{lang: nested dict}` (or {})."""
_IDLE.clear()
_IDLE.update(overlay or {})
_MERGED.clear()
_GEN[0] += 1
def generation() -> int:
"""Changes whenever `set_idle()` does, so a cache of words can tell."""
return _GEN[0]
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback (and with the
roster's own sentences laid over it when a unit is handed back)."""
if lang not in _IDLE:
return _file(lang)
if lang not in _MERGED:
_MERGED[lang] = _merge(_file(lang), _IDLE[lang])
return _MERGED[lang]
def _merge(base, over):
if not (isinstance(base, dict) and isinstance(over, dict)):
return copy.deepcopy(over)
+2 -1
View File
@@ -442,7 +442,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""Restart the add-on, at the operator's explicit request.
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
wind-down commands 0 W on every inverter, or hands a unit back when its
driver does (`Controller.shutdown()`, SMA-10), so
the request must carry the intent token; a reload, a retry or a stray
POST cannot spend a control interruption. It restarts THIS ADD-ON and
nothing else: no setpoint is written from here, and a latched failsafe