Files
goodwe-addon/goodwe_controller/test_sma10_two_idle_states.py
T
Claude 8953be0362
checks / unit (pull_request) Successful in 22m59s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-137: the shutdown and Stop 0 W is the last write every unit hears
The 0 W of shutdown and of the Stop button now waits (bounded, 2 s) for a
write already on its way to the same unit, for every driver, GoodWe included.
Once shutdown has begun, cycle, heartbeat and safety-stop writes are dropped
right before the write is created. One shared wait (_await_inflight) serves
the 0 W and SMA-10's _release_last.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-08 02:39:38 +01:00

1214 lines
48 KiB
Python

"""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)
_real_time = M.time
class _FakeTime:
"""A clock the test moves; everything else is the real `time`."""
t = 1000.0
def monotonic(self):
return self.t
def __getattr__(self, name):
return getattr(_real_time, name)
FT = _FakeTime()
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.")
# NL default "de eigen sturing" (PR #239 review comment 2495): same article as
# the generic clause in handback._clause.
check("NL: a hand-back driver with no words goes back to 'de eigen sturing'",
handback._BACK_NL == "de eigen sturing"
and handback.stop_dialog((handback.unit_of(bare_inv),), "nl") ==
"De omvormer gaat terug naar de eigen sturing tot u de regeling "
"opnieuw start.")
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"(release {M.RELEASE_BUDGET_S:.1f} s + fallback {M.FALLBACK_BUDGET_S:.1f}"
f" s = {M.HAND_BACK_WORST_S:.1f} s at most)")
check("the bound: it returns within RELEASE_BUDGET_S + FALLBACK_BUDGET_S plus "
"a margin, however many units hang (they run together)",
took < M.HAND_BACK_WORST_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"
FT.t = _real_time.monotonic() + 11.0 # one heartbeat later
M.time = FT
run(c.send_heartbeats(60_000.0, 10.0))
M.time = _real_time
check("...and the next idle write (a heartbeat later) 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.HAND_BACK_WORST_S + 1.5 and n_calls(fc_ok, "release") == 1
and h.writes()[-1] == 0.0)
# --- 3h2. a failed release: one 0 W, retries at heartbeat pace, logged once ---
print(" (a failed release: the unit carries 1120 W)")
_cap = []
class _Cap2(logging.Handler):
def emit(self, rec):
_cap.append((rec.levelno, rec.getMessage()))
_h2 = _Cap2()
logging.getLogger("goodwe").addHandler(_h2)
def failing_site(mode):
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b")])
f = fakes(c)[0]
run(cycles(c, 5))
f.power = 1120.0 # the unit carries 1120 W
f.mode = mode
return c, h, f
for mode in ("false", "raise", "hang"):
c, h, f = failing_site(mode)
n_w = len(writes_of(f))
t0 = time.monotonic()
run(c.handle_action("auto_toggle")) # Stop
took = time.monotonic() - t0
print(f" Stop, release {mode}: {took:.2f} s; calls after: "
f"{[x for x in f.calls[-4:]]}")
check(f"FAILED RELEASE ({mode}): the unit that carried 1120 W gets exactly "
f"one 0 W write after the release, inside the bound",
writes_of(f)[n_w:] == [0.0] and n_calls(f, "release") == 1
and took < M.HAND_BACK_WORST_S + 1.5
and not c.inverters[1].released)
run(c.send_heartbeats(10_000.0, 10.0))
run(cycles(c, 3))
check(f"FAILED RELEASE ({mode}): heartbeats and cycles in the same moment "
f"add no write and no retry",
writes_of(f)[n_w:] == [0.0] and n_calls(f, "release") == 1)
# the cadence and the log, over N failed retries on a clock we move
c, h, f = failing_site("false")
M.time = FT
FT.t = 5000.0
_cap.clear()
run(c.handle_action("auto_toggle")) # Stop: try 1 (forced)
n_w = len(writes_of(f))
N = 6
for k in range(N):
for step in range(5): # idle writes inside a heartbeat
FT.t += 1.9
run(c.send_heartbeats(20_000.0 + FT.t, 10.0))
run(cycles(c, 1))
FT.t += 1.0 # the heartbeat is due
run(c.send_heartbeats(40_000.0 + FT.t, 10.0))
print(f" {N} heartbeats: release() called {n_calls(f, 'release')} times, "
f"0 W writes after the first: {writes_of(f)[n_w:]}")
check("CADENCE: one try at Stop plus one per heartbeat, not one per idle "
"write", n_calls(f, "release") == 1 + N)
check("ONE 0 W only, however many retries fail: the inverter's fallback timer "
"is not reset", writes_of(f)[n_w - 1:] == [0.0])
errs = [m for lv, m in _cap if lv >= logging.ERROR and "inverter-b" in m]
evs = [e for e in c.events if "hand-back failed" in str(e)]
check(f"LOG: {N + 1} failed tries make ONE error line and ONE action-log event",
len(errs) == 1 and len(evs) == 1)
f.mode = "ok"
FT.t += 11.0
run(c.send_heartbeats(90_000.0, 10.0))
for _ in range(3):
FT.t += 11.0
run(c.send_heartbeats(95_000.0 + FT.t, 10.0))
inf = [m for lv, m in _cap if "worked again" in m]
check("RECOVERY: logged once ('worked again after 7 failed tries'), released, "
"and no further release() calls",
len(inf) == 1 and "7 failed" in inf[0] and c.inverters[1].released
and n_calls(f, "release") == 2 + N
and len([e for e in c.events if "inverter-b: handed back" in str(e)]) == 1)
run(c.handle_action("auto_toggle")) # Start
run(cycles(c, 3))
f.mode = "false"
_cap.clear()
run(c.handle_action("auto_toggle")) # Stop, fails again
check("a NEW failure streak is logged again, once",
len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1)
M.time = _real_time
# shutdown with a failed release: the one 0 W still goes out inside the bound
c, h, f = failing_site("hang")
n_w = len(writes_of(f))
t0 = time.monotonic()
run(c.shutdown())
took = time.monotonic() - t0
print(f" shutdown, release hangs: {took:.2f} s (worst {M.HAND_BACK_WORST_S:.1f} s)")
check("SHUTDOWN with a failed release: the one 0 W is sent, inside the bound",
writes_of(f)[n_w:] == [0.0] and took < M.HAND_BACK_WORST_S + 1.5)
check("a driver's own fallback replaces the 0 W (the hook), not the add-on",
"release_fallback" in dir(HB) and HB.release_fallback is
drivers.Driver.release_fallback)
# a failed streak that Start interrupts WITHOUT a recovery: the next Stop is a
# new streak with its own one 0 W, its own error line and its own event
c, h, f = failing_site("false")
_cap.clear()
run(c.handle_action("auto_toggle")) # Stop, release fails
w1 = writes_of(f).count(0.0)
run(c.handle_action("auto_toggle")) # Start, no recovery
iv = c.inverters[1]
run(cycles(c, 5))
nz = [w for w in writes_of(f) if w]
check("START after a failed release: control writes non-zero again and the "
"failed-streak state is cleared",
nz and writes_of(f)[-1] != 0.0 and iv.release_fails == 0
and not iv.release_zero_sent and iv.release_retry_at == 0.0)
n_before = len(writes_of(f))
run(c.handle_action("auto_toggle")) # Stop, release fails again
after = writes_of(f)[n_before:]
errs = [m for lv, m in _cap if lv >= logging.ERROR and "inverter-b" in m]
evs = [e for e in c.events if "hand-back failed" in str(e)]
check("SECOND STREAK (Start came between, no recovery): the unit gets its own "
"one 0 W write",
after == [0.0] and w1 == 1)
check("SECOND STREAK: a second error line and a second action-log event",
len(errs) == 2 and len(evs) == 2)
run(c.send_heartbeats(70_000.0, 10.0))
check("...and still only the one 0 W in that streak",
writes_of(f)[n_before:] == [0.0])
# the hook: a driver's own fallback replaces the 0 W, and _hand_back does not
# call write(0) itself
class HookHB(HB):
name = "hookhb"
async def release_fallback(self):
self.calls.append(("fallback", None))
return await self.write(5.0) # a non-zero safe value
REGISTRY["hookhb"] = HookHB
c, h, p = site([gw("inverter-a", "a"), fk("inverter-b", "hookhb")])
f = fakes(c)[0]
run(cycles(c, 4))
f.mode = "false"
n0 = len(f.calls)
run(c.handle_action("auto_toggle"))
tail = [x for x in f.calls[n0:] if x[0] in ("release", "fallback", "write")]
print(f" a driver with its own fallback: {tail}")
check("HOOK: after a failed release the driver's release_fallback() is called "
"once, its own value (5 W) is the only write, and the add-on does not "
"write 0 W itself",
tail == [("release", None), ("fallback", None), ("write", 5.0)])
logging.getLogger("goodwe").removeHandler(_h2)
# --- 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.<name>(...)` in main.py (SAFETY-137:
the all-GoodWe branch passes `last=True`)."""
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.
# n. (round 2) the fallback 0 W is sent on every failed try.
# o. (round 2) a failed release is retried on every idle write.
# p. (round 2) every failed try is logged.
# q. (round 2) no fallback 0 W after a failed release.
# r. (round 3) Start does not clear a failed streak.
# s. (round 3) _hand_back writes 0 W itself instead of calling the hook.