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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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
|
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
|
stage timeout), so one slow integration holds a start stage for about 5
|
||||||
at most. The live start took 414 s.
|
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
|
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
|
reading", which sends the reader to Developer Tools for an entity that is
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
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
|
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__).
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app import hass as _hass
|
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])
|
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")
|
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")
|
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")
|
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]))
|
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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED")
|
print(f"{len(fails)} FAILED")
|
||||||
|
|||||||
Reference in New Issue
Block a user