SMA-10: two idle states, hold 0 W or hand the inverter back
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:
co-authored by
Claude
parent
57c8d1b912
commit
a1be9c48b3
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user