SMA-10: two idle states, hold 0 W or hand the inverter back #239

Merged
admin merged 4 commits from SMA-10 into release/1.0 2026-11-07 23:43:15 +01:00
12 changed files with 1972 additions and 21 deletions
+1
View File
@@ -2,6 +2,7 @@
### Not in a release yet
- When the add-on stops, an inverter that can steer itself is handed back instead of held at 0 W. Safety stops still hold 0 W, and a GoodWe site is not affected ([[SMA-10]]).
- Each inverter is now reached through a driver, so another make can be added later. Nothing changes for a GoodWe site ([[SMA-2]]).
- Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]).
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
@@ -0,0 +1,62 @@
# 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; one without them gets "its own control" /
"de eigen sturing" (the article as in the generic clause; PR #239 review
comment 2495, not the brief). 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).
**After a failed release** (returns False, raises or hangs past the bound) the
unit gets one `write(0)` through `Driver.release_fallback()` (default
`write(0)`; a driver where 0 W is not always safe overrides it), then nothing:
a repeated 0 W would keep resetting the inverter's own fallback timer, so after
that the unit is left to its own fallback. The release is retried once per
heartbeat; one error line and one action-log event per failure streak, one line
on recovery. Worst case at shutdown is `RELEASE_BUDGET_S` + `FALLBACK_BUDGET_S`
(6 s).
+24 -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,21 @@ 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 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:
@@ -159,7 +179,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 = "de 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]
+15
View File
@@ -275,6 +275,21 @@ 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()
# 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
+193 -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,19 @@ __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
# 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.
@@ -561,6 +576,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 +4062,115 @@ 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, *, 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.
"""
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.
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, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
except Exception as err: # noqa: BLE001 - one unit never stops the rest
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))
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))
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():
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 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).
inv.released = False
inv.last_write = None
inv.release_fails = 0
inv.release_retry_at = 0.0
inv.release_zero_sent = False
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 +4294,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 +6916,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 +7183,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, force=True) 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 +8174,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 +8220,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 +8261,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, 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",
@@ -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
+7
View File
@@ -793,6 +793,7 @@ from datetime import datetime as _dt, timedelta as _td, timezone as _tz # noqa:
from app import clamp as _clamp, freshness as _fresh, peakguard as _pg # noqa: E402
from app import store as _store # noqa: E402
from app import handback # noqa: E402
from app.circulation import CirculationConfig, CirculationWatchdog # noqa: E402
from app.control import Tuning, compute # noqa: E402
from app.delivery import (DeliveryConfig, DeliveryWatchdog, UnitDelivery, # noqa: E402
@@ -1058,6 +1059,12 @@ TEXT = {
"main.py:messages.reason_code(inv.batt_fresh.reason)": _coded(_stale_texts),
"main.py:rep.explain()": _migration_texts,
"store.py:rep.explain()": _migration_texts,
# SMA-10: the control-task CRITICAL line's state, for a GoodWe-only roster
# (the sentence it always was) and a mixed one.
"main.py:handback.task_died_state(handback.current())": lambda: [
handback.task_died_state(()),
handback.task_died_state((handback.Unit("inverter-a"),
handback.Unit("inverter-b", True)))],
"main.py:plan.words": _tariff_texts,
# OPT-11 P2: why an MQTT command was ignored.
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
File diff suppressed because it is too large Load Diff