- 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 <noreply@anthropic.com>
441 lines
14 KiB
Python
441 lines
14 KiB
Python
"""SAFETY-112: waiting for Home Assistant to finish starting. `python3 -B test_safety112.py`
|
|
|
|
The Supervisor answers 502 to every add-on read, write and websocket until
|
|
Home Assistant is RUNNING (DECISIONS/SAFETY-112-waiting-for-home-assistant.md),
|
|
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": 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. The page script has it.
|
|
3. Control is unchanged: the same readings give the same writes with the
|
|
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
|
|
from app import messages
|
|
from app.main import Controller
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
class _Rec(logging.Handler):
|
|
def __init__(self):
|
|
super().__init__(logging.DEBUG)
|
|
self.records = []
|
|
|
|
def emit(self, record):
|
|
self.records.append(record)
|
|
|
|
|
|
def captured(fn):
|
|
rec, lg = _Rec(), logging.getLogger("goodwe.hass")
|
|
old = lg.level
|
|
lg.addHandler(rec)
|
|
lg.setLevel(logging.DEBUG)
|
|
try:
|
|
fn()
|
|
finally:
|
|
lg.removeHandler(rec)
|
|
lg.setLevel(old)
|
|
return [(r.levelno, r.getMessage()) for r in rec.records
|
|
if r.levelno >= logging.INFO]
|
|
|
|
|
|
class Clock:
|
|
"""`hass.time` stand-in: only `monotonic` is read by Outage."""
|
|
|
|
def __init__(self):
|
|
self.t = 1000.0
|
|
|
|
def monotonic(self):
|
|
return self.t
|
|
|
|
|
|
def with_clock(fn):
|
|
clock, real = Clock(), _hass.time
|
|
_hass.time = SimpleNamespace(monotonic=clock.monotonic)
|
|
try:
|
|
return fn(clock)
|
|
finally:
|
|
_hass.time = real
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. the log while Home Assistant does not answer")
|
|
W, I = logging.WARNING, logging.INFO
|
|
|
|
|
|
def outage(clock):
|
|
o = _hass.Outage()
|
|
o.down("api", "error 502", "read sensor.x: 502")
|
|
for t in (1, 60, 299):
|
|
clock.t = 1000 + t
|
|
o.down("api", "error 502", "read sensor.x: 502")
|
|
clock.t = 1300
|
|
o.down("api", "error 502", "") # 5 minutes: the first reminder
|
|
clock.t = 1301
|
|
o.down("websocket", "the meter connection closed", "")
|
|
clock.t = 1599
|
|
o.down("api", "error 502", "") # 299 s after the reminder: none
|
|
clock.t = 1600
|
|
o.down("api", "error 502", "") # 10 minutes: the second
|
|
clock.t = 1640
|
|
o.up("api")
|
|
o.up("websocket") # 640 s in all
|
|
clock.t = 2000
|
|
o.down("api", "no connection", "") # a new outage starts afresh
|
|
clock.t = 2030
|
|
o.up("api")
|
|
|
|
|
|
lines = with_clock(lambda c: captured(lambda: outage(c)))
|
|
check(f"one WARNING per outage, in plain words ({lines})",
|
|
[m for lv, m in lines if lv == W] == [
|
|
"Home Assistant is not answering (error 502), perhaps it is still "
|
|
"starting. Control waits and resumes by itself.",
|
|
"Home Assistant is not answering (no connection), perhaps it is "
|
|
"still starting. Control waits and resumes by itself."])
|
|
check("one 'still waiting' line per 5 minutes down, with the time so far",
|
|
[m for lv, m in lines if lv == I and m.startswith("Still")] == [
|
|
"Still waiting for Home Assistant to finish starting, 5 minutes "
|
|
"so far.",
|
|
"Still waiting for Home Assistant to finish starting, 10 minutes "
|
|
"so far."])
|
|
check("one INFO line when the last channel is back, with the wait, and a new "
|
|
"outage counts from its own start",
|
|
[m for lv, m in lines if lv == I and m.startswith("Home")] == [
|
|
"Home Assistant answers again after 11 minutes.",
|
|
"Home Assistant answers again after 30 seconds."])
|
|
|
|
|
|
def channels(_clock):
|
|
o = _hass.Outage()
|
|
seen = [o.waiting()]
|
|
o.down("websocket", "the meter connection closed", "")
|
|
seen.append(o.waiting())
|
|
o.down("api", "error 502", "")
|
|
seen.append(o.waiting())
|
|
o.up("websocket")
|
|
seen.append(o.waiting())
|
|
o.up("api")
|
|
seen.append(o.waiting())
|
|
return seen
|
|
|
|
|
|
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")
|
|
|
|
|
|
class _Hass:
|
|
session = None
|
|
token = ""
|
|
|
|
def __init__(self):
|
|
self.writes = []
|
|
|
|
async def limits(self, _e):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def number(self, _e, invert=False):
|
|
return None # what a 502 read gives
|
|
|
|
async def state(self, _e):
|
|
return None
|
|
|
|
async def set_number(self, e, v):
|
|
self.writes.append((e, float(v)))
|
|
return False # what a 502 write gives
|
|
|
|
async def call(self, *_a):
|
|
return False
|
|
|
|
async def states(self):
|
|
return None
|
|
|
|
|
|
class _Store:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get(self, k, d=None):
|
|
return self.data.get(k, d)
|
|
|
|
def set_time(self, k, v):
|
|
self.data[k] = v.isoformat()
|
|
|
|
def get_time(self, k):
|
|
return None
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, *_a, **_k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
|
"maintenance_enabled": False, "stale_input_s": 15}
|
|
TWO = dict(ONE, setpoint_entity="", soc_entity="", batt_entity="", inverters=[
|
|
{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity":
|
|
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
|
"bms_soc_floor": 10},
|
|
{"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity":
|
|
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
|
"bms_soc_floor": 10}])
|
|
|
|
|
|
def make(opts, hass=None):
|
|
c = Controller(dict(opts), hass or _Hass(), _Store(), _Pub(),
|
|
stop=asyncio.Event())
|
|
c.auto = True
|
|
for inv in c.inverters:
|
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
|
return c
|
|
|
|
|
|
def waiting_on():
|
|
_hass.OUTAGE.down_on = {"api"}
|
|
_hass.OUTAGE.since = _hass.OUTAGE._told = _hass.time.monotonic()
|
|
|
|
|
|
def waiting_off():
|
|
_hass.OUTAGE.down_on = set()
|
|
|
|
|
|
BANNER = "Not ready — Waiting for Home Assistant to finish starting"
|
|
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
|
c = make(opts)
|
|
waiting_off()
|
|
plain = c.checks()
|
|
pay_off = c.status_payload()
|
|
waiting_on()
|
|
rows = c.checks()
|
|
pay = c.status_payload()
|
|
pay_nl = c.status_payload(lang="nl")
|
|
waiting_off()
|
|
check(f"{label}: no waiting row while Home Assistant answers",
|
|
not [r for r in plain if r.get("id") == "home_assistant"]
|
|
and pay_off["state"]["kind"] != "waiting")
|
|
check(f"{label}: while it does not, the waiting row is first and red",
|
|
rows[0] == {"ok": False, "warn": False, "id": "home_assistant",
|
|
"banner": messages.banner_ha_waiting(),
|
|
"text": messages.row_ha_waiting()}
|
|
and rows[1:] == plain)
|
|
check(f"{label}: ...and it is the banner ({pay['banner']!r})",
|
|
pay["banner"] == BANNER and pay["level"] == "bad")
|
|
check(f"{label}: Home says waiting, not 'Needs you now' "
|
|
f"({pay['state']['pill']!r}, {pay['state']['sub']!r})",
|
|
pay["state"]["kind"] == "waiting"
|
|
and pay["state"]["pill"] == "Waiting for Home Assistant"
|
|
and pay["state"]["sub"] == "Control resumes by itself when Home "
|
|
"Assistant answers.")
|
|
check(f"{label}: ...and in Dutch ({pay_nl['state']['pill']!r})",
|
|
pay_nl["state"]["pill"] == "Wacht op Home Assistant"
|
|
and pay_nl["state"]["sub"] == "De regeling gaat vanzelf verder "
|
|
"zodra Home Assistant antwoordt.")
|
|
check(f"{label}: Needs attention lists it first, with its own title",
|
|
pay["attention"][0]["id"] == "home_assistant"
|
|
and pay["attention"][0]["title"] == "Home Assistant is not answering"
|
|
and pay_nl["attention"][0]["title"] == "Home Assistant antwoordt "
|
|
"niet")
|
|
|
|
# A safety stop outranks the wait: it still needs a person.
|
|
c = make(TWO)
|
|
c.inverters[1].sign_wd.tripped = True
|
|
waiting_on()
|
|
pay = c.status_payload()
|
|
waiting_off()
|
|
check(f"a safety stop keeps its banner and 'Needs you now' over the wait "
|
|
f"({pay['banner']!r}, {pay['state']['kind']!r})",
|
|
pay["banner"] == messages.banner_direction(True)
|
|
and pay["state"]["kind"] == "alarm")
|
|
# A stalled control loop does not resume by itself, so it stays the headline.
|
|
c = make(ONE)
|
|
c.loop_pass_mono = 0.0
|
|
waiting_on()
|
|
rows, pay = c.checks(), c.status_payload()
|
|
waiting_off()
|
|
check(f"a stalled control loop stays first and keeps 'Needs you now' "
|
|
f"({pay['banner']!r})",
|
|
[r["id"] for r in rows[:2]] == ["controller", "home_assistant"]
|
|
and pay["state"]["kind"] == "alarm")
|
|
c = make(dict(ONE, shadow_mode="on"))
|
|
waiting_on()
|
|
pay = c.status_payload()
|
|
waiting_off()
|
|
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")
|
|
|
|
|
|
def run(opts, flag):
|
|
async def go():
|
|
h = _Hass()
|
|
c = make(opts, h)
|
|
(waiting_on if flag else waiting_off)()
|
|
targets = []
|
|
for _ in range(4):
|
|
await c.read_inputs()
|
|
await c.cycle()
|
|
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
|
targets.append(c.target)
|
|
waiting_off()
|
|
return targets, h.writes
|
|
return asyncio.run(go())
|
|
|
|
|
|
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
|
off, on = run(opts, False), run(opts, True)
|
|
check(f"{label}: same targets and writes with the flag on or off "
|
|
f"({on[0]}, {len(on[1])} writes)", off == on)
|
|
check(f"{label}: ...and every write while Home Assistant does not answer "
|
|
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")
|
|
for f in fails:
|
|
print(" -", f)
|
|
raise SystemExit(1)
|
|
print("all passed")
|