diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6f40b67..7ff0cfe 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -5370,27 +5370,32 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event, stop.set() +DOCS_LINK_TIMEOUT_S = 3 + + async def docs_link_facts(session, token: str) -> dict: """`{slug, ha_version}` from the Supervisor, each None when unreadable. `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). + Asked together with a 3 s budget: a link may never hold up control start. """ - out = {"slug": None, "ha_version": None} - for key, url, field in (("slug", "http://supervisor/addons/self/info", - "slug"), - ("ha_version", "http://supervisor/info", - "homeassistant")): + async def ask(url, field): 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: body = await r.json(content_type=None) 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 _LOG.info("docs link: %s unreadable (%s)", url, err) - _LOG.info("docs link: slug %s, Home Assistant %s", out["slug"], - out["ha_version"]) + return None + + 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 diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 44dd295..24f6868 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -146,9 +146,9 @@ LIMIT_WORDS = { def loop_bound() -> Notice: """SAFETY-40: the integrator bound. Normal in short bursts; not a fault.""" return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND, - "The battery does less than asked, which is normal briefly, " - "but if this keeps happening, check that it is on and not " - "empty or full.") + "The battery gives less than asked, which is normal for a " + "moment, but if it lasts, check it is on and not empty " + "or full.") @dataclass(frozen=True) @@ -358,7 +358,7 @@ def banner_shadow(latched: bool) -> str: clears. """ 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.") 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: """Red. The inverter is dropped and the others carry its share.""" - return (f"{name} cannot be controlled. Every command was refused for " - f"{seconds(held_s)}, limit {seconds(limit_s)}.") + return (f"{name} cannot be controlled. It refused every command for " + f"{seconds(held_s)}, so control leaves it out.") def row_commands_refused(name, held_s, limit_s) -> str: diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index 30b77ee..fd94efd 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -213,10 +213,11 @@ class _Resp: class _Session: def __init__(self, answers): - self.answers, self.asked = answers, [] + self.answers, self.asked, self.timeouts = answers, [], [] def get(self, url, **kw): self.asked.append((url, kw.get("headers", {}).get("Authorization"))) + self.timeouts.append(kw.get("timeout")) return _Resp(self.answers[url]) @@ -236,6 +237,10 @@ check("...and a Supervisor that does not answer costs the link, never the " "start", asyncio.run(docs_link_facts(_down, "tok")) == { "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 # text may say a command was sent or that the battery is giving power. diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 982fa97..9c71f42 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -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 " "visible nowhere at all", _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 = {} _c.mqtt = type("P", (), {"enabled": True, "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()) and "Total command" 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 = {} _c31.mqtt = type("P", (), {"enabled": True, "publish": lambda _s, v: _pub30.update(v),