diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 0af8a9f..5d02a9f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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]]). diff --git a/goodwe_controller/DECISIONS/SMA-10-two-idle-states.md b/goodwe_controller/DECISIONS/SMA-10-two-idle-states.md new file mode 100644 index 0000000..778b1b6 --- /dev/null +++ b/goodwe_controller/DECISIONS/SMA-10-two-idle-states.md @@ -0,0 +1,51 @@ +# SMA-10 - two idle states: hold 0 W, or hand the inverter back + +**Status: for review.** Tests: `test_sma10_two_idle_states.py`. Source: SMA-9 +report sections 1.4, 3.2; owner decision 2026-10-20 (Q1, "as proposed"). + +A unit is idle in one of two ways, and the driver says which: +`Driver.hands_back`. **False (GoodWe):** the add-on holds 0 W, `release()` is +`write(0)` and the Controller never calls it, so Stop keeps today's 0 W write and +today's 0 W heartbeat. **True:** `release()` gives the unit back to whatever +steers it by itself (SMA energy management), once, and then nothing is written +to it until control starts again. + +**Why two.** When the add-on goes away (Stop, shutdown, SIGTERM, restart, task +death, a config refusal) nobody is left to hold a number, so the unit goes back +to its own controller. When the add-on itself decides to stop (sign trip, site +sign, circulating power, a refused or non-finite write, missing readings, +another writer, an inverter fault) it has just said it cannot trust that +battery, and handing it to something else would let that something do what it +likes with it. Those hold 0 W through `write(0)` for every unit, whatever its +driver. Shadow mode on a unit that hands back uses `release()`: it must leave +the unit alone, and 0 W would be a command. + +**Where.** `Controller.command_site_release()` sits beside `command_site_zero()` +(unchanged). Stop and `shutdown()` call it; the two maintenance-abort paths keep +zero (the add-on runs on, the sentence says 0 W for a moment). With no +hand-back unit it is `command_site_zero()`. `_write_unit()` also refuses to write +a hand-back unit while the add-on is idle (stopping, stopped, or shadow mode): it +releases once and returns, so the heartbeat and the cycle's own "stopped" 0 W +cannot reach it. A safety stop runs while control runs, so it falls through to +the write. Start re-arms: `released` drops and `last_write` clears. + +**Bounds.** `RELEASE_BUDGET_S` equals `WRITE_BUDGET_S` (4 s): units are released +together, so one slow unit costs the kill window no more than a hung GoodWe +write does. A release at the bound is abandoned, not awaited. A release that +raises, hangs or returns false leaves that unit un-handed (the next idle write +tries again) and does not touch the others. Exit 78 before the Controller +exists has no driver to release and keeps today's path. + +**Words.** Every sentence that said "set to 0 W" for the add-on going away or +for shadow mode is built from the roster in `app/handback.py` (EN and NL): a +roster with no hand-back unit gets None and keeps its old literal, so a GoodWe +site sees no changed byte. A hand-back unit's words (`Driver.words`: `back`, +`back_nl`) come from its driver. MQTT `status` is unchanged; the Home battery +row of a handed-back unit says "Back under SMA control" and carries +`handed_back`. + +**Not done here (SMA-4, SMA-5, SMA-11):** any SMA/FoxESS driver, the read-back +after `release()`, per-driver timing, DOCS.md's safety model and the +"control stalled" sentence, the site-level "Held at 0 W" tiles in shadow mode on +a mixed roster, and the MQTT per-unit setpoint of a handed-back unit (it still +publishes 0). diff --git a/goodwe_controller/app/drivers.py b/goodwe_controller/app/drivers.py index 98d616e..915a891 100644 --- a/goodwe_controller/app/drivers.py +++ b/goodwe_controller/app/drivers.py @@ -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 diff --git a/goodwe_controller/app/handback.py b/goodwe_controller/app/handback.py new file mode 100644 index 0000000..b0c5a74 --- /dev/null +++ b/goodwe_controller/app/handback.py @@ -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 ." 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 diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index e2812d3..ce644f1 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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] diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index d5fa66a..69161d0 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index eb14dd7..a34cc7f 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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() diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 831da93..3e24bc1 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -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 ." 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) diff --git a/goodwe_controller/app/uitext.py b/goodwe_controller/app/uitext.py index d6caf53..85b0487 100644 --- a/goodwe_controller/app/uitext.py +++ b/goodwe_controller/app/uitext.py @@ -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) diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index d6a0551..f3f5b62 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -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 diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index c0fd4ae..3636a2a 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -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 @@ -850,7 +851,7 @@ DATA = { "main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w", "main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held", "main.py:value", "main.py:float(value)", - "main.py:entity", "main.py:WRITE_BUDGET_S", + "main.py:entity", "main.py:WRITE_BUDGET_S", "main.py:RELEASE_BUDGET_S", "main.py:_e", "main.py:messages.times(self.clamp_wd.engagements)", "main.py:messages.when(brussels(self.tariff_at))", @@ -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()), diff --git a/goodwe_controller/test_sma10_two_idle_states.py b/goodwe_controller/test_sma10_two_idle_states.py new file mode 100644 index 0000000..7bad08b --- /dev/null +++ b/goodwe_controller/test_sma10_two_idle_states.py @@ -0,0 +1,1023 @@ +"""SMA-10: two idle states, hold 0 W or hand the inverter back. `python3 test_sma10_two_idle_states.py` + +A unit whose driver says `hands_back` is given back (`release()`) when the add-on +goes away and in shadow mode; every safety stop and refusal still holds 0 W. A +GoodWe unit never sees a changed byte. + +1. GoodWe only: every sentence this card builds from the roster is the literal + it was before the card, in English and Dutch, and the page words are the + files as they were. +2. The sentences for a mixed and a hand-back-only roster, English and Dutch, + printed, held to STYLE.md, and taken from the DRIVER'S words. +3. A real Controller with a fake hand-back unit (and a GoodWe unit): Stop, + shutdown, a safety stop, Start, shadow mode, the per-unit state, the call + sites, one unit's release raising or hanging. +4. The real main() in a child process: SIGTERM, the control task's death, a late + config refusal (exit 78) and a startup refusal (exit 78, no driver exists). + +Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). +The mutants this file was run against are listed at the end. +""" + +import asyncio +import json +import logging +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE / "tools")) + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +# --------------------------------------------------------------------------- # +# the child: the real main() with the outside world faked (as test_control_task) +# --------------------------------------------------------------------------- # +CHILD_BASE = { + "meter_entity": "sensor.grid", "meter_source": "off", "gain": 0.6, + "slew_w": 0, "deadband_w": 15, "target_grid_w": -10, "step_w": 10, + "heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 1, + "auto_start": True, "shadow_mode": "off", + "inverters": [ + dict(name="inverter-a", setpoint_entity="number.sp_a", + soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", + max_w=2000, bms_soc_floor=10), + dict(name="inverter-b", driver="hbfake", host="10.9.9.9", port=502, + unit_id=3, max_w=2000, bms_soc_floor=10)]} +DEADLINE_S = 40 + + +def make_hb_driver(event=None): + from app.drivers import Driver, Probe + + class HBDriver(Driver): + """A unit that hands back. Calls are kept; `mode` decides how its + release() behaves: ok, false, raise or hang.""" + name = "hbfake" + writer_key = "host" + brand = "Fake" + hands_back = True + words = {"back": "SMA control", "back_nl": "de sturing van SMA"} + + def __init__(self, host="", port=502, unit_id=1, rating=2000.0): + self.host, self.port, self.unit_id = host, port, unit_id + self.rating, self.power = rating, 0.0 + self.calls = [] + self.mode = "ok" + self.write_ok = True + self.write_delay = 0.0 + + @classmethod + def from_entry(cls, entry): + return cls(entry.get("host", ""), entry.get("port", 502), + entry.get("unit_id", 1)) + + @classmethod + def actuator_id_of(cls, entry): + host = str(entry.get("host") or "").strip() + return f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}" \ + if host else "" + + def configured(self): + return bool(self.host) + + def actuator_id(self): + return f"{self.host}:{self.port}:{self.unit_id}" + + def soc_source(self): + return "fake-soc" + + def batt_source(self): + return "fake-power" + + async def limits(self): + return -self.rating, self.rating + + async def read(self): + return 60.0, self.power, {} + + async def write(self, w): + self.calls.append(("write", w)) + if event: + event("fake", "write", float(w)) + if self.write_delay: + await asyncio.sleep(self.write_delay) + self.calls.append(("write_done", w)) + if self.write_ok: + self.power = w + return self.write_ok + + async def release(self): + self.calls.append(("release", None)) + if event: + event("fake", "release") + if self.mode == "raise": + raise RuntimeError("release exploded") + if self.mode == "false": + return False + if self.mode == "hang": + await asyncio.sleep(3600) + return True + + async def probe(self): + return Probe(-self.rating, self.rating, 60.0, self.power, {}) + + return HBDriver + + +def child(scenario): + import aiohttp + import app.main as M + import app.store + import app.web + from app import drivers + + def event(*e): + print("EV " + json.dumps(list(e)), flush=True) + + drivers.REGISTRY["hbfake"] = make_hb_driver(event) + drivers.SELECTABLE = drivers.SELECTABLE + ("hbfake",) + t0 = time.monotonic() + + class FakeHass: + def __init__(self): + self.limit_calls = 0 + + async def limits(self, entity): + self.limit_calls += 1 + if scenario == "refuse": # a rating below max_w, once read + return -1000.0, 1000.0, 1.0 + return -5000.0, 5000.0, 1.0 + + async def number(self, entity, invert=False): + if entity == "sensor.grid": + return 600.0 + (int(time.monotonic() - t0) % 2) + if entity == "sensor.soc_a": + return 50.0 + if entity == "sensor.batt_a": + return 0.0 + return None + + async def state(self, entity): + return None + + async def set_number(self, entity, value): + event("goodwe", "write", float(value)) + return True + + async def call(self, *a, **k): + return True + + async def mqtt_service(self): + raise RuntimeError("no broker in this test") + + class _Store: + data = {} + + def get(self, key, default=None): + return default + + def set(self, *a, **k): + pass + + class _Runner: + async def cleanup(self): + event("cleanup") + + async def _web_start(controller, port=8099, selfcfg=None): + return _Runner() + + class _NoProbeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + def get(self, *a, **k): + raise OSError("no network in this test") + + opts = json.loads(json.dumps(CHILD_BASE)) + if scenario == "badcfg": + opts["slew_w"] = -5 # refused at the startup gate + M.load_options = lambda: dict(opts) + M.HomeAssistant = lambda session: FakeHass() + app.store.Store = _Store + app.web.start = _web_start + aiohttp.ClientSession = _NoProbeSession + + real_cycle = M.Controller.cycle + n = {"cycles": 0} + + async def cycle(self): + n["cycles"] += 1 + if scenario == "fault" and n["cycles"] == 4: + event("fault") + raise RuntimeError("injected SMA-10 fault") + await real_cycle(self) + if scenario == "sigterm" and n["cycles"] == 4: + event("sigterm") + os.kill(os.getpid(), signal.SIGTERM) + + M.Controller.cycle = cycle + M.main() + + +def run_child(scenario): + env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") + try: + p = subprocess.run([sys.executable, "-B", __file__, "--child", scenario], + cwd=str(HERE), env=env, capture_output=True, + text=True, timeout=DEADLINE_S) + except subprocess.TimeoutExpired as e: + rc, out, err = None, e.stdout or "", e.stderr or "" + else: + rc, out, err = p.returncode, p.stdout, p.stderr + out = out.decode() if isinstance(out, bytes) else out + err = err.decode() if isinstance(err, bytes) else err + return rc, err, [json.loads(ln[3:]) for ln in out.splitlines() + if ln.startswith("EV ")] + + +if len(sys.argv) == 3 and sys.argv[1] == "--child": + child(sys.argv[2]) + sys.exit(0) + +# --------------------------------------------------------------------------- # +from app import drivers, handback, homeview, messages, uitext # noqa: E402 +from app import main as M # noqa: E402 +from app.drivers import GoodWeEsphomeDriver, REGISTRY # noqa: E402 +from app.main import Controller # noqa: E402 +from app.store import DEFAULTS # noqa: E402 +from style_rules import BANNED, SHOUT, sentences, words # noqa: E402 + +HB = make_hb_driver() +REGISTRY["hbfake"] = HB + + +class OtherHB(HB): + """A second make that hands back, with other words: the sentences must + come from the driver, not from a hard-coded brand.""" + name = "otherhb" + words = {"back": "Zeta control", "back_nl": "de sturing van Zeta"} + + +REGISTRY["otherhb"] = OtherHB + + +class MemStore: + def __init__(self): + self.data = dict(DEFAULTS) + + def set(self, k, v): + self.data[k] = v + + def get_time(self, *a): + return None + + def set_time(self, *a): + pass + + +class Pub: + enabled = False + + def __init__(self): + self.last = {} + + def publish(self, values): + self.last = dict(values) + + def close(self): + pass + + +class LogHass: + def __init__(self): + self.log = [] + self.numbers = {"sensor.grid": 3000.0, "sensor.soc_a": 50.0, + "sensor.batt_a": 0.0} + + async def number(self, entity, invert=False): + self.log.append(("number", entity, invert)) + return self.numbers.get(entity) + + async def limits(self, entity): + self.log.append(("limits", entity, None)) + return (-3000.0, 3000.0, 1.0) + + async def set_number(self, entity, value): + self.log.append(("set_number", entity, value)) + return True + + async def state(self, entity): + return None + + async def call(self, *a): + return True + + def writes(self): + return [v for m, e, v in self.log if m == "set_number"] + + +def run(coro): + return asyncio.run(coro) + + +def gw(name="inverter-a", suffix="a"): + return dict(name=name, setpoint_entity=f"number.sp_{suffix}", + soc_entity=f"sensor.soc_{suffix}", + batt_entity=f"sensor.batt_{suffix}", max_w=3000, + bms_soc_floor=10) + + +def fk(name="inverter-b", driver="hbfake", host="10.9.9.9"): + return dict(name=name, driver=driver, host=host, port=502, unit_id=3, + max_w=2000, bms_soc_floor=10) + + +def site(inverters, auto=True, **over): + opts = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000, + gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10, + heartbeat_s=10, stale_input_s=15, shadow_mode="off", + inverters=inverters) + opts.update(over) + hass = LogHass() + pub = Pub() + c = Controller(opts, hass, MemStore(), pub, stop=asyncio.Event()) + for inv in c.inverters: + inv.dev_min, inv.dev_max = -3000.0, 3000.0 + c.auto = auto + return c, hass, pub + + +def fakes(c): + return [i.driver for i in c.inverters if isinstance(i.driver, HB)] + + +def n_calls(drv, what): + return len([1 for w, _a in drv.calls if w == what]) + + +def writes_of(drv): + return [a for w, a in drv.calls if w == "write"] + + +async def cycles(c, n=4): + for _ in range(n): + await c.read_inputs() + await c.cycle() + + +# ============================================================================= +print("SMA-10 - two idle states") +print() +print("1. a GoodWe-only roster: every sentence is what it was before this card") + +BASE = { + "en": { + "stop": "Every inverter is set to 0 W until you start control again.", + "restart": "Every inverter is set to 0 W while the add-on restarts, and " + "then the new settings apply.", + "shadow_restart": "Every inverter is set to 0 W while the add-on " + "restarts, and shadow mode keeps them at 0 W after " + "that.", + "setup_shadow": "The add-on restarts with these settings, and shadow " + "mode keeps every inverter at 0 W.", + "attn": "The add-on decides everything, but every inverter stays at " + "0 W.", + "ctl_shadow": "Shadow mode: every inverter stays at 0 W.", + "ctl_stopped": "Control is stopped. Every inverter is at 0 W.", + "banner_stopped": "Stopped — the inverters are set to 0 W", + "banner_shadow": "Shadow mode: the battery is not controlled. " + "Inverters stay at 0 W.", + "banner_shadow_latched": "Shadow mode: battery not controlled. A " + "safety stop stays on, see below.", + "hint": "Shadow mode on, inverters stay at 0 W", + }, + "nl": { + "stop": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.", + "restart": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, " + "daarna gelden de nieuwe instellingen.", + "shadow_restart": "Elke omvormer gaat naar 0 W terwijl de add-on " + "herstart, en de schaduwmodus houdt ze daarna op " + "0 W.", + "setup_shadow": "De add-on herstart met deze instellingen, en de " + "schaduwmodus houdt elke omvormer op 0 W.", + "attn": "De add-on beslist alles, maar elke omvormer blijft op 0 W.", + "ctl_shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", + "ctl_stopped": "De regeling is gestopt. Elke omvormer staat op 0 W.", + "banner_stopped": "Gestopt — de omvormers staan op 0 W", + "banner_shadow": "Schaduwmodus: de batterij wordt niet geregeld. " + "Omvormers blijven op 0 W.", + "banner_shadow_latched": "Schaduwmodus: batterij niet geregeld. Een " + "veiligheidsstop blijft aan, zie hieronder.", + }, +} +CLAMP_SHADOW = [ + "Shadow mode is on, so every inverter stays at 0 W.", + "Shadow mode is on, so the inverter stays at 0 W.", + "and shadow mode keeps every inverter at 0 W.", +] + + +def page_sentences(lang): + """The nine page sentences this card moves, as the page gets them.""" + r = uitext.raw(lang) + s, hw = r["settings"], r["home_words"] + return {"stop": r["home"]["dialog"]["stop"]["description"], + "restart": s["dialog"]["description"], + "shadow_restart": s["setup"]["dialog"]["description"], + "setup_shadow": s["setup"]["dialog_setup"]["description"], + "attn": hw["attn_shadow_body"], "ctl_shadow": hw["control"]["shadow"], + "ctl_stopped": hw["control"]["stopped"]} + + +cg, _hg, _pg = site([gw("inverter-a", "a"), gw("inverter-b", "b")]) +check("a GoodWe-only Controller leaves the roster with no hand-back unit", + not handback.active(handback.current()) and uitext.generation() > 0) +for lang in ("en", "nl"): + got = page_sentences(lang) + check(f"[{lang}] the seven page sentences are the literals from before", + all(got[k] == BASE[lang][k] for k in got)) + on_disk = json.loads((HERE / "app" / "ui_text" / f"{lang}.json") + .read_text(encoding="utf-8")) + check(f"[{lang}] the whole page word file is the file on disk, byte for " + f"byte as data", uitext.raw(lang) == on_disk) +homeview._LANG[0] = "en" +check("[en] homeview reads the same control line", + homeview.control_text(False, False) == BASE["en"]["ctl_stopped"] + and homeview.control_text(True, True) == BASE["en"]["ctl_shadow"] + and homeview.attn_shadow_body() == BASE["en"]["attn"]) +homeview._LANG[0] = "nl" +check("[nl] homeview reads the same control line", + homeview.control_text(False, False) == BASE["nl"]["ctl_stopped"] + and homeview.control_text(True, True) == BASE["nl"]["ctl_shadow"] + and homeview.attn_shadow_body() == BASE["nl"]["attn"]) +homeview._LANG[0] = "en" +for key, fn in (("banner_stopped", messages.banner_stopped), + ("banner_shadow", lambda: messages.banner_shadow(False)), + ("banner_shadow_latched", lambda: messages.banner_shadow(True))): + t = fn() + check(f"{key}: English and Dutch are the literals from before", + str(t) == BASE["en"][key] and t.nl == BASE["nl"][key]) +check("hint shadow_on is the literal from before", + messages.hint("shadow_on") == BASE["en"]["hint"]) +bodies = " | ".join([messages.output_clamp((), shadow=True).body, + messages.output_clamp( + [messages.Clamp("a", 5, 4, "rating")], + shadow=True).body, + messages.output_clamp( + [messages.Clamp("a", 5, 4, "rating"), + messages.Clamp("b", 5, 4, "rating")], + shadow=True).body]) +check("the three shadow-mode output-clamp sentences are the literals from " + "before", all(x in bodies for x in CLAMP_SHADOW)) +_logs = [] + + +class _Cap(logging.Handler): + def emit(self, rec): + _logs.append(rec.getMessage()) + + +_h = _Cap() +logging.getLogger("goodwe").addHandler(_h) +logging.getLogger("goodwe").setLevel(logging.DEBUG) +_LOG_NAMES = [n for n in logging.root.manager.loggerDict + if n.startswith("goodwe")] +run(cg.shutdown()) +check("the shutdown INFO line is the literal from before", + "shutting down - commanding 0 W on every inverter" in _logs) +_ev = asyncio.Event() +_t = type("T", (), {"cancelled": lambda s: False, + "exception": lambda s: RuntimeError("x")})() +M.control_task_done(_t, controller=type("C", (), {"stopping": False})(), + stop=_ev, died=[]) +check("the control-task CRITICAL line is the literal from before", + any(m == "Control stopped unexpectedly (RuntimeError('x')), so every " + "inverter is set to 0 W and the add-on exits with code 70." + for m in _logs)) +logging.getLogger("goodwe").removeHandler(_h) + +# ============================================================================= +print() +print("2. the sentences for a mixed and a hand-back-only roster") + + +def units(*inv): + return handback.units_of(site(list(inv))[0].inverters) + + +MIXED = units(gw("inverter-a", "a"), fk("inverter-b")) +ALLHB = units(fk("inverter-a"), fk("inverter-b", host="10.9.9.8")) +ONEHB = units(fk("inverter-b")) +TWO_MAKES = units(gw("inverter-a", "a"), fk("inverter-b"), + fk("inverter-c", "otherhb", "10.9.9.7")) +SHAPES = (("mixed (GoodWe + hand-back)", MIXED), + ("hand-back only (two)", ALLHB), ("hand-back only (one)", ONEHB), + ("GoodWe + two makes", TWO_MAKES)) +ROWS = (("stop dialog", handback.stop_dialog), + ("restart dialog", handback.restart_dialog), + ("restart dialog, shadow", handback.restart_dialog_shadow), + ("setup dialog, shadow", handback.setup_dialog_shadow), + ("control line, stopped", handback.control_stopped), + ("control line, shadow", handback.control_shadow), + ("attention body, shadow", handback.attn_shadow_body)) +problems = [] +for label, us in SHAPES: + print(f" --- {label}") + for what, fn in ROWS: + for lang in ("en", "nl"): + t = fn(us, lang) + print(f" {lang} {what}: {t}") + if len(words(t)) > 25 or sentences(t) > 2 or BANNED.search(t) \ + or SHOUT.search(t.replace("SMA", "S")): + if not (what.startswith("control line") and sentences(t) == 2): + problems.append((label, lang, what, t)) + b = handback.banner_stopped(us) + print(f" en banner stopped: {b}") + print(f" nl banner stopped: {b.nl}") + if len(words(str(b))) > 12 or len(words(b.nl)) > 12: + problems.append((label, "banner", str(b), b.nl)) + print(f" en hint shadow on: {handback.hint_shadow_on(us)}") +check("every sentence is plain and short (STYLE.md: 25 words, banner 12, no " + "banned words)", not problems) +if problems: + print(" " + repr(problems)) +m_en = handback.stop_dialog(MIXED, "en") +m_nl = handback.stop_dialog(MIXED, "nl") +check("EN stop dialog, mixed roster, is the card's sentence", + m_en == "Inverter-a is set to 0 W and inverter-b goes back to SMA " + "control until you start control again.") +check("NL stop dialog, mixed roster, is the card's sentence", + m_nl == "Inverter-a gaat naar 0 W en inverter-b gaat terug naar de " + "sturing van SMA tot u de regeling opnieuw start.") +check("hand-back only: 'The inverters go back to SMA control ...'", + handback.stop_dialog(ALLHB, "en") == + "The inverters go back to SMA control until you start control again." + and handback.stop_dialog(ONEHB, "en") == + "The inverter goes back to SMA control until you start control again." + and handback.stop_dialog(ALLHB, "nl") == + "De omvormers gaan terug naar de sturing van SMA tot u de regeling " + "opnieuw start.") +check("banner (mixed) is the card's: 'Stopped — inverter-a at 0 W, inverter-b " + "under SMA control'", + str(handback.banner_stopped(MIXED)) == + "Stopped — inverter-a at 0 W, inverter-b under SMA control" + and handback.banner_stopped(MIXED).nl == + "Gestopt — inverter-a op 0 W, inverter-b onder de sturing van SMA") +ZETA = units(gw(), fk("inverter-c", "otherhb")) +check("the words come from the DRIVER: another make's words, never 'SMA'", + handback.stop_dialog(ZETA, "en") == "Inverter-a is set to 0 W and " + "inverter-c goes back to Zeta control until you start control again." + and "de sturing van Zeta" in handback.stop_dialog(ZETA, "nl") + and "SMA" not in handback.stop_dialog(ZETA, "en") + and "SMA" not in str(handback.banner_stopped(ZETA))) +check("two makes in one roster: the sentence says 'its own control' rather " + "than run past 25 words", + "go back to their own control" in handback.restart_dialog(TWO_MAKES, "en") + and "SMA" not in handback.restart_dialog(TWO_MAKES, "en") + and "Zeta" not in handback.restart_dialog(TWO_MAKES, "en")) +bare = type("D", (), {"hands_back": True, "words": {}})() +bare_inv = type("I", (), {"name": "x", "driver": bare})() +check("a hand-back driver with no words gets a plain brand-free phrase", + handback.stop_dialog((handback.unit_of(bare_inv),), "en") == + "The inverter goes back to its own control until you start control " + "again.") +long_roster = tuple(handback.Unit(f"inverter-{c}", i % 2 == 1, "SMA control", + "de sturing van SMA") + for i, c in enumerate("abcdefg")) +lb = handback.banner_stopped(long_roster) +check("a long roster falls back to counts and the banner stays 12 words", + len(words(str(lb))) <= 12 and len(words(lb.nl)) <= 12 + and "3 under SMA control" in str(lb)) +print(f" long roster banner: {lb} / {lb.nl}") + +# the Controller builds the page from its own roster +cm, hm, pm = site([gw("inverter-a", "a"), fk("inverter-b")]) +check("a Controller with a hand-back unit puts the sentences in the page words", + page_sentences("en")["stop"] == m_en + and page_sentences("nl")["stop"] == m_nl + and "SMA control" in page_sentences("en")["ctl_stopped"] + and messages.banner_stopped() == handback.banner_stopped(MIXED) + and messages.hint("shadow_on") == handback.hint_shadow_on(MIXED)) +from app import web # noqa: E402 +html = web.page() +check("...and the assembled page carries them (and not the old stop sentence)", + m_en in html and BASE["en"]["stop"] not in html and m_nl in html) +homeview._LANG[0] = "nl" +check("homeview (nl) says the same in its control line", + homeview.control_text(False, False) == page_sentences("nl")["ctl_stopped"]) +homeview._LANG[0] = "en" +check("the shadow banner and the clamp notice no longer say every inverter " + "stays at 0 W", + "0 W" not in str(messages.banner_shadow(False)) + and "stays at 0 W and inverter-b stays under SMA control" in + messages.output_clamp((), shadow=True).body) +print(f" shadow banner: {messages.banner_shadow(False)}") +print(f" shadow clamp: {messages.output_clamp((), shadow=True).body}") + +# ============================================================================= +print() +print("3. a real Controller with a fake hand-back unit") + + +async def hb_pause(): + await asyncio.sleep(0) + + +# --- 3a. Stop: one release; GoodWe 0 W + heartbeat; no later write ----------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +f = fakes(c)[0] +spy = {"zero": 0, "release": 0} +_z, _r = c.command_site_zero, c.command_site_release + + +async def spy_zero(): + spy["zero"] += 1 + await _z() + + +async def spy_release(): + spy["release"] += 1 + await _r() + + +c.command_site_zero, c.command_site_release = spy_zero, spy_release +run(cycles(c, 5)) +check("precondition: both units were being written a non-zero share while " + "control ran", any(v for v in writes_of(f)) and any(h.writes())) +n_f = len(writes_of(f)) +n_g = len(h.writes()) + + +async def stop_then_beat(): + await c.handle_action("auto_toggle") # auto True -> False: Stop + out = {} + out["after_stop_f"] = len(f.calls) + for k in range(4): # heartbeats well apart + await c.send_heartbeats(10_000.0 + 100 * k, 10.0) + await cycles(c, 3) # and the cycle's own writes + return out + + +r = run(stop_then_beat()) +check("STOP: the button calls command_site_release(), not command_site_zero()", + spy["release"] == 1 and spy["zero"] == 0) +check("STOP: the fake unit got exactly one release() and nothing after it", + n_calls(f, "release") == 1 and f.calls[-1] == ("release", None) + and len(writes_of(f)) == n_f) +check("STOP: ...no write at all from the heartbeat or the cycle, after it", + len(f.calls) == r["after_stop_f"] and c.inverters[1].released) +gw_after = h.writes()[n_g:] +check("STOP: the GoodWe unit got 0 W, and its heartbeat and cycle go on " + "writing 0 W (today's behaviour)", + gw_after and set(gw_after) == {0.0} and len(gw_after) >= 2) +check("STOP: the MQTT status word is unchanged: 'stopped'", + (c.publish(), p.last.get("status"))[1] == "stopped") + +# --- 3b. shutdown after Stop: still one release ------------------------------ +run(c.shutdown()) +check("SHUTDOWN after Stop: still exactly one release(); GoodWe got its 0 W", + n_calls(f, "release") == 1 and h.writes()[-1] == 0.0 + and spy["release"] == 2 and spy["zero"] == 0) + +# --- 3c. shutdown from running, then a late heartbeat ------------------------ +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +f = fakes(c)[0] +run(cycles(c, 5)) +n_f = len(f.calls) + + +async def down_then_beat(): + await c.shutdown() + last = h.writes()[-1] + await c.send_heartbeats(50_000.0, 10.0) + await cycles(c, 2) # (the real loop has ended by now) + return last + + +gw_last = run(down_then_beat()) +check("SHUTDOWN while running: one release() for the fake unit, no write " + "after it (heartbeat and cycle included)", + n_calls(f, "release") == 1 and f.calls[n_f:] == [("release", None)]) +check("SHUTDOWN while running: the GoodWe unit is at 0 W", + gw_last == 0.0) + +# --- 3d. a safety stop on the same unit: 0 W written, never released --------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +f = fakes(c)[0] +run(cycles(c, 5)) +fb = c.inverters[1] + + +async def safety(): + c.circ_wd.tripped, c.circ_wd.reason = True, "test" # circulating power + await c.cycle() + await c.cycle() + + +n0 = len(f.calls) +precondition = writes_of(f)[-1] != 0.0 +run(safety()) +check("SAFETY STOP (circulating power): the fake unit, which was carrying " + "power, is written 0 W and its release() is never called", + precondition and writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0 + and not fb.released and len(f.calls) > n0) + + +async def nonfinite(): + await c._write_unit(fb, float("nan"), force=True) + + +c.allocation = None +run(nonfinite()) +check("SAFETY STOP (non-finite value): 0 W on the fake unit, no release()", + writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0) + + +async def refused_write(): + await c._write_unit(fb, 777.0, force=True) # nothing authorised this + + +c.allocation = None +run(refused_write()) +check("SAFETY STOP (write refused): an unauthorised 777 W becomes 0 W on the " + "fake unit; release() is never called", + writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0) +run(c.command_site_zero()) +check("the maintenance abort path (command_site_zero) holds 0 W on the fake " + "unit too while control runs, with no release()", + writes_of(f)[-1] == 0.0 and n_calls(f, "release") == 0) + +# --- 3e. Start writes again --------------------------------------------------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +f = fakes(c)[0] +run(cycles(c, 4)) +run(c.handle_action("auto_toggle")) # Stop +check("precondition: handed back after Stop", + c.inverters[1].released and n_calls(f, "release") == 1) +n0 = len(writes_of(f)) +run(c.handle_action("auto_toggle")) # Start +run(cycles(c, 5)) +check("START: control writes to the unit again, and it is no longer " + "'released'", len(writes_of(f)) > n0 and not c.inverters[1].released + and any(v != 0.0 for v in writes_of(f)[n0:])) +run(c.handle_action("auto_toggle")) # Stop again +check("...and a second Stop hands it back again (one release() per Stop)", + n_calls(f, "release") == 2) + +# --- 3e2. a write already on its way is finished BEFORE the release ---------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +f = fakes(c)[0] +f.write_delay = 0.6 + + +async def race(): + fb = c.inverters[1] + t = asyncio.ensure_future(c._write_unit(fb, 0.0, force=True)) + await asyncio.sleep(0.1) # the write is on its way + c.auto = False # Stop + await c.command_site_release() + await t + + +run(race()) +print(f" calls on the unit: {[w for w, _a in f.calls]}") +check("RACE: a write on its way when Stop is pressed finishes first and the " + "release() is the LAST thing the unit hears", + [w for w, _a in f.calls] == ["write", "write_done", "release"] + and c.inverters[1].released) + +# --- 3f. shadow mode ---------------------------------------------------------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")], shadow_mode="on") +f = fakes(c)[0] +run(cycles(c, 5)) +run(c.send_heartbeats(20_000.0, 10.0)) +check("SHADOW: the fake hand-back unit is released once and never written", + n_calls(f, "release") == 1 and n_calls(f, "write") == 0 + and c.shadow) +check("SHADOW: the GoodWe unit is held at 0 W as today", + h.writes() and set(h.writes()) == {0.0}) +c2, h2, p2 = site([gw("inverter-a", "a"), gw("inverter-b", "b")], + shadow_mode="on") +run(cycles(c2, 5)) +run(c2.send_heartbeats(20_000.0, 10.0)) +check("SHADOW, GoodWe only: unchanged, 0 W on both units", + h2.writes() and set(h2.writes()) == {0.0}) +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")], auto=False, + shadow_mode="auto") +f = fakes(c)[0] +run(cycles(c, 3)) +check("SHADOW (auto, control stopped): released once, no writes", + n_calls(f, "release") == 1 and n_calls(f, "write") == 0) + +# --- 3g. mixed/hand-back-only rosters at the edges --------------------------- +c, h, p = site([fk("inverter-a"), fk("inverter-b", host="10.9.9.8")]) +fa, fb2 = fakes(c) +run(cycles(c, 4)) +run(c.handle_action("auto_toggle")) +run(c.send_heartbeats(30_000.0, 10.0)) +check("HAND-BACK ONLY roster: each unit released once, no write after, and " + "Home Assistant was asked about nothing but the meter", + n_calls(fa, "release") == 1 and n_calls(fb2, "release") == 1 + and fa.calls[-1] == ("release", None) + and fb2.calls[-1] == ("release", None) + and not h.writes()) + +# --- 3h. one unit's release() raising, failing or hanging -------------------- +print(" (the next checks wait for the release bound)") +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b", host="10.1.1.1"), + fk("inverter-c", host="10.1.1.2"), + fk("inverter-d", host="10.1.1.3"), + fk("inverter-e", host="10.1.1.4")]) +fb_raise, fc_hang, fd_ok, ff_false = fakes(c) +fb_raise.mode, fc_hang.mode, ff_false.mode = "raise", "hang", "false" +run(cycles(c, 3)) +c.auto = False # what the Stop button does +t0 = time.monotonic() +run(c.command_site_release()) +took = time.monotonic() - t0 +print(f" command_site_release() with one raising, one hanging and one " + f"failing unit: {took:.2f} s " + f"(bound {M.RELEASE_BUDGET_S:.1f} s)") +check("the bound: it returns within RELEASE_BUDGET_S plus a margin, however " + "many units hang (they run together)", + took < M.RELEASE_BUDGET_S + 1.5) +check("the healthy unit was released (once) and the GoodWe unit zeroed, " + "though three others raised, failed or hung", + n_calls(fd_ok, "release") == 1 and c.inverters[3].released + and h.writes()[-1] == 0.0) +check("the units that raised, failed or hung are NOT marked handed back, so " + "they are tried again", not any(c.inverters[i].released + for i in (1, 2, 4))) +fb_raise.mode = "ok" +run(c.send_heartbeats(60_000.0, 10.0)) +check("...and the next idle write retries the one that raised (now answers): " + "released, and the healthy unit is not released a second time", + n_calls(fb_raise, "release") == 2 and c.inverters[1].released + and n_calls(fd_ok, "release") == 1) +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b"), + fk("inverter-c", host="10.1.1.2")]) +fb_h, fc_ok = fakes(c) +fb_h.mode = "hang" +t0 = time.monotonic() +run(c.shutdown()) +took = time.monotonic() - t0 +check("shutdown() with one unit hanging finishes inside the bound, the other " + "unit is released and the GoodWe unit at 0 W", + took < M.RELEASE_BUDGET_S + 1.5 and n_calls(fc_ok, "release") == 1 + and h.writes()[-1] == 0.0) + +# --- 3i. per-unit state and the other counts --------------------------------- +c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")]) +run(cycles(c, 4)) +rows = {r["name"]: r for r in homeview.battery_rows(c, False, True, False)} +check("before Stop neither unit says it is handed back", + "handed_back" not in rows["inverter-a"] + and "handed_back" not in rows["inverter-b"]) +run(c.handle_action("auto_toggle")) +homeview._LANG[0] = "en" +rows = {r["name"]: r for r in homeview.battery_rows(c, False, False, False)} +print(f" per-unit state after Stop: inverter-a: {rows['inverter-a']['now']!r}" + f"; inverter-b: {rows['inverter-b']['now']!r}") +check("PER-UNIT STATE: the handed-back unit says so in its driver's words " + "(not '0 W'); the GoodWe unit does not change", + rows["inverter-b"].get("handed_back") is True + and rows["inverter-b"]["now"] == "Back under SMA control" + and "handed_back" not in rows["inverter-a"] + and "0 W" in rows["inverter-a"]["now"]) +homeview._LANG[0] = "nl" +rows = {r["name"]: r for r in homeview.battery_rows(c, False, False, False)} +check("PER-UNIT STATE (nl)", + rows["inverter-b"]["now"] == "Terug onder de sturing van SMA") +homeview._LANG[0] = "en" +check("the MQTT status word is 'stopped' as before", + (c.publish(), p.last.get("status"))[1] == "stopped") +check("the log says which unit was handed back", + any("inverter-b: handed back" in str(e) for e in c.events)) +check("the two new Last-event hints exist and are plain", + messages.hint("handed_back", unit="inverter-b").endswith("to it") + and messages.hint("hand_back_failed", unit="inverter-b") + .endswith("trying again")) + +# --- 3j. the call sites ------------------------------------------------------- +src = (HERE / "app" / "main.py").read_text(encoding="utf-8") +ss = src.splitlines() + + +def calls_in(name): + """Line numbers of every `await self.()` in main.py.""" + return [i + 1 for i, ln in enumerate(ss) if f"await self.{name}()" in ln] + + +rel, zer = calls_in("command_site_release"), calls_in("command_site_zero") +print(f" main.py call sites: command_site_release at lines {rel}; " + f"command_site_zero at lines {zer}") +check("main.py: the Stop button and shutdown() call command_site_release(); " + "the two maintenance-abort paths keep command_site_zero() (a third call " + "is the all-GoodWe branch inside command_site_release itself)", + len(rel) == 2 and len(zer) == 3 + and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]])) + +# ============================================================================= +print() +print("4. the real main() in a child process") + + +def events_of(ev, who, what): + return [e for e in ev if e[0] == who and e[1] == what] + + +rc, err, ev = run_child("sigterm") +print(f" SIGTERM: exit status {rc}; fake events " + f"{[e[1:] for e in ev if e[0] == 'fake']}") +i_sig = ev.index(["sigterm"]) if ["sigterm"] in ev else None +after = ev[i_sig + 1:] if i_sig is not None else [] +check("SIGTERM: the process ends cleanly (0)", rc == 0) +check("SIGTERM: the fake unit is released exactly once, after the signal, and " + "written nothing after that", + len(events_of(after, "fake", "release")) == 1 + and [e for e in after if e[0] == "fake"][-1] == ["fake", "release"] + and ["cleanup"] in after) +check("SIGTERM: the GoodWe unit gets 0 W on the way out", + [e for e in after if e[0] == "goodwe"][-1] == ["goodwe", "write", 0.0]) +check("SIGTERM: the fake unit was written before the signal (the test is not " + "vacuous)", len(events_of(ev[:i_sig], "fake", "write")) >= 1) +check("SIGTERM: the log says what happened to each unit", + "shutting down - commanding 0 W on inverter-a and handing back " + "inverter-b to SMA control" in err) + +rc, err, ev = run_child("fault") +i_f = ev.index(["fault"]) if ["fault"] in ev else None +after = ev[i_f + 1:] if i_f is not None else [] +print(f" task death: exit status {rc}; after the fault " + f"{[e[1:] for e in after if e[0] in ('fake', 'goodwe')]}") +check("TASK DEATH: exit status 70", rc == 70) +check("TASK DEATH: the fake unit is released exactly once and nothing is " + "written to it after; the GoodWe unit is at 0 W", + len(events_of(after, "fake", "release")) == 1 + and [e for e in after if e[0] == "fake"][-1] == ["fake", "release"] + and [e for e in after if e[0] == "goodwe"][-1] == ["goodwe", "write", 0.0]) +check("TASK DEATH: the CRITICAL line says 0 W for one and hand-back for the " + "other", + "so inverter-a is set to 0 W and inverter-b is handed back and the " + "add-on exits with code 70" in err) + +rc, err, ev = run_child("refuse") +print(f" late config refusal: exit status {rc}; events " + f"{[e[1:] for e in ev if e[0] in ('fake', 'goodwe')]}") +check("CONFIG REFUSAL after the drivers exist: exit status 78, the fake unit " + "released exactly once, never written", + rc == 78 and len(events_of(ev, "fake", "release")) == 1 + and not events_of(ev, "fake", "write")) +check("CONFIG REFUSAL: the GoodWe unit is at 0 W", + events_of(ev, "goodwe", "write") + and events_of(ev, "goodwe", "write")[-1] == ["goodwe", "write", 0.0]) + +rc, err, ev = run_child("badcfg") +print(f" startup refusal: exit status {rc}; events {ev}") +check("CONFIG REFUSAL before any driver exists: exit 78 as today, and nothing " + "is released or written", rc == 78 and not ev) + +# ============================================================================= +print() +if fails: + print(f"{len(fails)} FAILED:") + for n in fails: + print(" -", n) + sys.exit(1) +print("all passed") + +# --------------------------------------------------------------------------- +# MUTANTS this file was run against (one edit each to a copy of the tree; every +# one turns it red). The two the card names are the first two. +# a. a safety stop (a 0 W write while control runs) calls release(): the +# idle test in Controller._write_unit gains `or value == 0.0`. +# b. the heartbeat (and the cycle) write to a released unit: the `return` +# after the idle hand-back in _write_unit is removed. +# c. the unit is released twice (Stop, then shutdown): the `if inv.released: +# return True` guard in _hand_back is removed. +# d. the Stop button calls command_site_zero() instead of the release. +# e. shutdown() calls command_site_zero() instead of the release. +# f. shadow mode is not idle for a hand-back unit. +# g. Start does not re-arm a released unit. +# h. the release is awaited without a bound (a hanging unit holds shutdown). +# i. a raising release() is not caught (it stops the others). +# j. a GoodWe unit is "released" instead of zeroed on Stop. +# k. the release does not wait for a write on its way (it is not the last). +# l. the hand-back words are hard-coded "SMA control", not the driver's. +# m. a GoodWe-only roster gets a changed banner.