UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s

Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-27 02:12:10 +02:00
co-authored by Claude Opus 5.5
parent 48ef06ac1f
commit d017fc67bf
19 changed files with 989 additions and 578 deletions
+25 -20
View File
@@ -369,10 +369,12 @@ check("0 W actually reached the device", 0.0 in hass.writes)
check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}")
check("the status page says so in words",
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
check("the status page says so in words: a red row, at 0 W, cleared by start",
any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
and "set to 0 W" in c["text"] and "start control" in c["text"]
for c in ctrl.checks()))
check("the banner leads with it",
"FAILSAFE" in ctrl.status_payload()["banner"])
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -479,7 +481,7 @@ check("...commands 0 W from its very first cycle", post.target == 0.0)
check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe",
"FAILSAFE" in post.status_payload()["banner"])
post.status_payload()["banner"] == messages.banner_direction(False))
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
# tripped, so the first press stops control; only the transition back to True
# clears. That is the documented behaviour and it is worth an assertion, because
@@ -952,12 +954,12 @@ check("...and it got there through the `circulation` claim, by name, rather "
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
for ck in _c.checks()))
any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "meter cannot see" in ck["text"] for ck in _c.checks()))
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",
"CIRCULATING POWER" in _c.status_payload()["banner"])
_c.status_payload()["banner"] == messages.banner_circulating())
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
@@ -1028,7 +1030,8 @@ check("...no `circulation` claim is ever filed, so the arbitration is "
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
any("circulating power: n/a at one inverter" in ck["text"]
any(ck.get("id") == "circulating" and ck["ok"]
and "not needed with one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
@@ -1911,9 +1914,11 @@ check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"]
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"])
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
@@ -2142,16 +2147,16 @@ check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and n
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the sentence that shipped",
_c1s.status_payload()["banner"]
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
check("...the banner is the one-inverter sentence, naming the meter",
_c1s.status_payload()["banner"] == messages.banner_direction(False)
and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
len([ck for ck in _c1s.checks()
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
]) == 1
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
and not any("inverter-" in ck["text"] for ck in _c1s.checks()
if ck.get("id") == "direction"))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
@@ -2323,12 +2328,12 @@ check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED,
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE")
and "its own measured power" in _bn_b["banner"])
and _bn_b["banner"] == messages.banner_direction(True)
and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
not _bn_n["banner"].startswith("FAILSAFE")
_bn_n["banner"] != messages.banner_direction(True)
and _bn_n["banner"] != _bn_b["banner"])