UX-09 review: short start-up timeout, latch rows pinned in shadow, plainer rows
The Supervisor questions for the Details link are asked together with a 3 s budget instead of one after the other at 10 s each, so a hung Supervisor cannot hold control start for 20 s. In shadow mode the circulating and site-direction latches are pinned to keep their own red row that says how they clear. Plainer inverter and battery wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5370,27 +5370,32 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
|||||||
stop.set()
|
stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
DOCS_LINK_TIMEOUT_S = 3
|
||||||
|
|
||||||
|
|
||||||
async def docs_link_facts(session, token: str) -> dict:
|
async def docs_link_facts(session, token: str) -> dict:
|
||||||
"""`{slug, ha_version}` from the Supervisor, each None when unreadable.
|
"""`{slug, ha_version}` from the Supervisor, each None when unreadable.
|
||||||
|
|
||||||
`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass
|
`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass
|
||||||
list for an add-on's own token, so they need no role (see selfconfig.py).
|
list for an add-on's own token, so they need no role (see selfconfig.py).
|
||||||
|
Asked together with a 3 s budget: a link may never hold up control start.
|
||||||
"""
|
"""
|
||||||
out = {"slug": None, "ha_version": None}
|
async def ask(url, field):
|
||||||
for key, url, field in (("slug", "http://supervisor/addons/self/info",
|
|
||||||
"slug"),
|
|
||||||
("ha_version", "http://supervisor/info",
|
|
||||||
"homeassistant")):
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=10, headers={
|
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
|
||||||
"Authorization": f"Bearer {token}"}) as r:
|
"Authorization": f"Bearer {token}"}) as r:
|
||||||
body = await r.json(content_type=None)
|
body = await r.json(content_type=None)
|
||||||
data = body.get("data", body) if isinstance(body, dict) else {}
|
data = body.get("data", body) if isinstance(body, dict) else {}
|
||||||
out[key] = data.get(field) if isinstance(data, dict) else None
|
return data.get(field) if isinstance(data, dict) else None
|
||||||
except Exception as err: # noqa: BLE001 - a link, never a stop
|
except Exception as err: # noqa: BLE001 - a link, never a stop
|
||||||
_LOG.info("docs link: %s unreadable (%s)", url, err)
|
_LOG.info("docs link: %s unreadable (%s)", url, err)
|
||||||
_LOG.info("docs link: slug %s, Home Assistant %s", out["slug"],
|
return None
|
||||||
out["ha_version"])
|
|
||||||
|
slug, ha = await asyncio.gather(
|
||||||
|
ask("http://supervisor/addons/self/info", "slug"),
|
||||||
|
ask("http://supervisor/info", "homeassistant"))
|
||||||
|
out = {"slug": slug, "ha_version": ha}
|
||||||
|
_LOG.info("docs link: slug %s, Home Assistant %s", slug, ha)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ LIMIT_WORDS = {
|
|||||||
def loop_bound() -> Notice:
|
def loop_bound() -> Notice:
|
||||||
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
|
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
|
||||||
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
|
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
|
||||||
"The battery does less than asked, which is normal briefly, "
|
"The battery gives less than asked, which is normal for a "
|
||||||
"but if this keeps happening, check that it is on and not "
|
"moment, but if it lasts, check it is on and not empty "
|
||||||
"empty or full.")
|
"or full.")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -358,7 +358,7 @@ def banner_shadow(latched: bool) -> str:
|
|||||||
clears.
|
clears.
|
||||||
"""
|
"""
|
||||||
if latched:
|
if latched:
|
||||||
return ("Shadow mode, battery not controlled. A safety stop stays on, "
|
return ("Shadow mode: battery not controlled. A safety stop stays on, "
|
||||||
"see below.")
|
"see below.")
|
||||||
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
|
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
|
||||||
|
|
||||||
@@ -491,8 +491,8 @@ def row_no_setpoint(name) -> str:
|
|||||||
|
|
||||||
def row_not_controllable(name, held_s, limit_s) -> str:
|
def row_not_controllable(name, held_s, limit_s) -> str:
|
||||||
"""Red. The inverter is dropped and the others carry its share."""
|
"""Red. The inverter is dropped and the others carry its share."""
|
||||||
return (f"{name} cannot be controlled. Every command was refused for "
|
return (f"{name} cannot be controlled. It refused every command for "
|
||||||
f"{seconds(held_s)}, limit {seconds(limit_s)}.")
|
f"{seconds(held_s)}, so control leaves it out.")
|
||||||
|
|
||||||
|
|
||||||
def row_commands_refused(name, held_s, limit_s) -> str:
|
def row_commands_refused(name, held_s, limit_s) -> str:
|
||||||
|
|||||||
@@ -213,10 +213,11 @@ class _Resp:
|
|||||||
|
|
||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, answers):
|
def __init__(self, answers):
|
||||||
self.answers, self.asked = answers, []
|
self.answers, self.asked, self.timeouts = answers, [], []
|
||||||
|
|
||||||
def get(self, url, **kw):
|
def get(self, url, **kw):
|
||||||
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
|
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
|
||||||
|
self.timeouts.append(kw.get("timeout"))
|
||||||
return _Resp(self.answers[url])
|
return _Resp(self.answers[url])
|
||||||
|
|
||||||
|
|
||||||
@@ -236,6 +237,10 @@ check("...and a Supervisor that does not answer costs the link, never the "
|
|||||||
"start",
|
"start",
|
||||||
asyncio.run(docs_link_facts(_down, "tok")) == {
|
asyncio.run(docs_link_facts(_down, "tok")) == {
|
||||||
"slug": None, "ha_version": None})
|
"slug": None, "ha_version": None})
|
||||||
|
check("...and each question has a short timeout, so a Supervisor that hangs "
|
||||||
|
f"delays control start by seconds, not minutes ({_ok.timeouts})",
|
||||||
|
len(_ok.timeouts) == 2
|
||||||
|
and all(isinstance(t, (int, float)) and 0 < t <= 3 for t in _ok.timeouts))
|
||||||
|
|
||||||
# Shadow mode (UX-09): nothing the add-on decides reaches the inverter, so no
|
# Shadow mode (UX-09): nothing the add-on decides reaches the inverter, so no
|
||||||
# text may say a command was sent or that the battery is giving power.
|
# text may say a command was sent or that the battery is giving power.
|
||||||
|
|||||||
@@ -960,6 +960,19 @@ check("the banner leads with it - above the sign failsafe, because a sign "
|
|||||||
"disagreement is visible on every dashboard in the house and this is "
|
"disagreement is visible on every dashboard in the house and this is "
|
||||||
"visible nowhere at all",
|
"visible nowhere at all",
|
||||||
_c.status_payload()["banner"] == messages.banner_circulating())
|
_c.status_payload()["banner"] == messages.banner_circulating())
|
||||||
|
# UX-09: the red shadow banner no longer names the latch, so the latch's own
|
||||||
|
# red row must stay on the list in shadow mode, name it and say how it clears.
|
||||||
|
_c_mode = _c.o.get("shadow_mode")
|
||||||
|
_c.o["shadow_mode"] = "on"
|
||||||
|
check("in shadow mode the banner goes red and says a safety stop stays on, and "
|
||||||
|
"the circulating row is still red and says only starting control clears "
|
||||||
|
"it",
|
||||||
|
_c.status_payload()["level"] == "bad"
|
||||||
|
and "safety stop" in _c.status_payload()["banner"]
|
||||||
|
and any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
|
||||||
|
and "Only starting control clears this" in ck["text"]
|
||||||
|
for ck in _c.checks()))
|
||||||
|
_c.o["shadow_mode"] = _c_mode
|
||||||
_pub = {}
|
_pub = {}
|
||||||
_c.mqtt = type("P", (), {"enabled": True,
|
_c.mqtt = type("P", (), {"enabled": True,
|
||||||
"publish": lambda _s, v: _pub.update(v),
|
"publish": lambda _s, v: _pub.update(v),
|
||||||
@@ -1919,6 +1932,18 @@ check("the status page carries a SITE row of its own, and the banner says the "
|
|||||||
for ck in _c31.checks())
|
for ck in _c31.checks())
|
||||||
and "Total command" in _c31.status_payload()["banner"]
|
and "Total command" in _c31.status_payload()["banner"]
|
||||||
and "inverter-" not in _c31.status_payload()["banner"])
|
and "inverter-" not in _c31.status_payload()["banner"])
|
||||||
|
_c31_mode = _c31.o.get("shadow_mode")
|
||||||
|
_c31.o["shadow_mode"] = "on"
|
||||||
|
check("UX-09: in shadow mode the banner goes red and says a safety stop stays "
|
||||||
|
"on, and the site row is still red and says only starting control "
|
||||||
|
"clears it",
|
||||||
|
_c31.status_payload()["level"] == "bad"
|
||||||
|
and "safety stop" in _c31.status_payload()["banner"]
|
||||||
|
and any(ck.get("id") == "site_direction" and not ck["ok"]
|
||||||
|
and not ck["warn"]
|
||||||
|
and "Only starting control clears this" in ck["text"]
|
||||||
|
for ck in _c31.checks()))
|
||||||
|
_c31.o["shadow_mode"] = _c31_mode
|
||||||
_pub30 = {}
|
_pub30 = {}
|
||||||
_c31.mqtt = type("P", (), {"enabled": True,
|
_c31.mqtt = type("P", (), {"enabled": True,
|
||||||
"publish": lambda _s, v: _pub30.update(v),
|
"publish": lambda _s, v: _pub30.update(v),
|
||||||
|
|||||||
Reference in New Issue
Block a user