From df6bc0f76bc40dddc79682eb827e29554889d5e1 Mon Sep 17 00:00:00 2001 From: goodwe-review Date: Tue, 13 Oct 2026 02:10:27 +0200 Subject: [PATCH] SAFETY-112 review: grade the waiting flag where hass.py sets it - a 502 or refused read turns waiting on, the next good read off, and a read timeout never (two mutants survived without this) - the flag changes no write with live readings, not only with 502 ones - the page script's amber dot and batteries-tab banner for "waiting" - DECISIONS: about 5 minutes per start stage, not "at most" Co-Authored-By: Claude Opus 5.5 --- .../SAFETY-112-waiting-for-home-assistant.md | 4 +- goodwe_controller/test_safety112.py | 114 +++++++++++++++++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md b/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md index abbee27..0d54065 100644 --- a/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md +++ b/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md @@ -51,8 +51,8 @@ Times are seconds after `docker restart` of Home Assistant: The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's -stage timeout), so one slow integration holds the start for about 5 minutes -at most. The live start took 414 s. +stage timeout), so one slow integration holds a start stage for about 5 +minutes. The whole start can take longer: the live one took 414 s. Before this change the page said "Not ready — Battery level: no usable reading", which sends the reader to Developer Tools for an entity that is diff --git a/goodwe_controller/test_safety112.py b/goodwe_controller/test_safety112.py index bdb6a9b..79d91e2 100644 --- a/goodwe_controller/test_safety112.py +++ b/goodwe_controller/test_safety112.py @@ -6,18 +6,21 @@ so the add-on cannot control then. This file grades what it says meanwhile: 1. The log: one WARNING when Home Assistant stops answering, one INFO line every 5 minutes while it stays down, one INFO line with the wait when it - answers again. Only the API channel counts as "waiting". + answers again. Only the API channel counts as "waiting": a 502 or a + refused connection turns it on, the next good read off, a timeout never. 2. The status page, N=1 and N=2: the waiting row is first and red, and is the banner. Home says "Waiting for Home Assistant" in English and Dutch, not - "Needs you now", unless a safety stop outranks it. + "Needs you now", unless a safety stop outranks it. The page script has it. 3. Control is unchanged: the same readings give the same writes with the - waiting flag on or off. + waiting flag on or off, with 502 readings and with live ones. Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). """ import asyncio import logging +import os +import re from types import SimpleNamespace from app import hass as _hass @@ -141,6 +144,66 @@ check("waiting() follows the API channel only, not the meter websocket", with_clock(channels) == [False, False, True, True, False]) +# The flag as `HomeAssistant.state()` sets it: a 502 or a refused connection +# turns it on, the next good read turns it off, and a timeout (a slow Home +# Assistant, not a starting one) leaves it alone. +class _Answer: + def __init__(self, what): + self.what = what + + async def __aenter__(self): + if isinstance(self.what, BaseException): + raise self.what + return self + + async def __aexit__(self, *_a): + return False + + @property + def status(self): + return self.what + + def raise_for_status(self): + pass + + async def json(self): + return {"state": "1"} + + +class _Session: + def __init__(self, answers): + self.answers = list(answers) + + def get(self, *_a, **_k): + return _Answer(self.answers.pop(0)) + + +def reads(answers): + async def go(): + real, _hass.OUTAGE = _hass.OUTAGE, _hass.Outage() + lg = logging.getLogger("goodwe.hass") + lg.disabled = True + try: + h = _hass.HomeAssistant(_Session(answers), token="t") + seen = [] + for _ in answers: + await h.state("sensor.x") + seen.append(_hass.OUTAGE.waiting()) + return seen + finally: + _hass.OUTAGE = real + lg.disabled = False + return asyncio.run(go()) + + +check("a 502 read turns waiting on and the next good read turns it off", + reads([502, 200, 503, 404]) == [True, False, True, False]) +check("a refused connection is waiting too", + reads([_hass.aiohttp.ClientConnectionError(), 200]) == [True, False]) +check("a read timeout is not waiting", + reads([TimeoutError(), TimeoutError()]) == [False, False]) + + # --------------------------------------------------------------------------- # print("2. the status page, N=1 and N=2") @@ -295,6 +358,18 @@ check("shadow mode keeps its own state over the wait", pay["state"]["kind"] == "shadow") +# The page script, read as text (the image has no JS engine): an amber dot, +# and the batteries tab carries the banner like the other held states. +JS = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app", + "static", "app.js"), encoding="utf-8").read() +check("the page script gives 'waiting' an amber dot", + re.search(r'alarm: " bad", waiting: " warn" \}\[st\.kind\]', JS) + is not None) +check("...and shows it as the batteries-tab banner", + '["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)' + in JS) + + # --------------------------------------------------------------------------- # print("3. control is unchanged by the waiting flag") @@ -323,6 +398,39 @@ for label, opts in (("N=1", ONE), ("N=2", TWO)): f"asks 0 W", on[1] and all(v == 0.0 for _e, v in on[1])) +# With 502 readings every write is 0 W whatever the flag says, so the check +# above cannot see a flag that forces 0 W. Live readings can: the flag alone +# must not change a non-zero write. +class _LiveHass(_Hass): + async def number(self, e, invert=False): + v = {"sensor.grid": 900.0}.get(e, 50.0 if "soc" in e else 0.0) + return -v if invert else v + + async def set_number(self, e, v): + self.writes.append((e, float(v))) + return True + + +def run_live(opts, flag): + async def go(): + h = _LiveHass() + c = make(opts, h) + (waiting_on if flag else waiting_off)() + for _ in range(4): + await c.read_inputs() + await c.cycle() + await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0) + waiting_off() + return h.writes + return asyncio.run(go()) + + +for label, opts in (("N=1", ONE), ("N=2", TWO)): + off, on = run_live(opts, False), run_live(opts, True) + check(f"{label}: with live readings the flag changes no write " + f"({on[-2:]})", off == on and any(v != 0.0 for _e, v in on)) + + print() if fails: print(f"{len(fails)} FAILED")