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
+37 -35
View File
@@ -1038,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
c.peak.cap_w == CAP_W)
check("...and before the first cycle the guard has NO decision, which the "
"status page must not spell as 'idle'", c.peak_guard is None)
pre = [r for r in c.checks() if "peak guard" in r["text"]]
pre = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row before the first cycle: "
+ (pre[0]["text"] if pre else "(none)"))
check("the status page says so in its own row",
@@ -1054,17 +1054,20 @@ c_off, hass_off = built(TMP / "legacy.db",
{"meter_source": "off", "meter_entity": "sensor.grid"})
c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
off_rows = [r for r in c_off.checks() if r.get("id") == "peak_guard"]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{nth(hass_off.writes, -1):+.0f} W")
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
check("with no P1 the guard does not run at all - it files nothing, and the "
"legacy path can still charge", c_off.peak_guard is None
and hass_off.writes and hass_off.writes[-1] < 0)
check("...and the status page says NOT COVERED rather than 'idle' or a green "
"row, which is the same answer SAFETY-18 already gives on a transport "
"that cannot see it", len(off_rows) == 1
and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"])
check("...and the status page says the guard is off, and why, rather than "
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
"transport that cannot see it", len(off_rows) == 1
and ": off" in off_rows[0]["text"]
and "meter source" in off_rows[0]["text"]
and "idle" not in off_rows[0]["text"]
and not off_rows[0]["ok"] and off_rows[0]["warn"])
# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY.
# `cycle()` reads the real clock, and `project()` measures the open block against
@@ -1214,7 +1217,7 @@ check("the real cycle files the peak claim and the real arbiter binds the real "
and not c.peak_guard.saturated and "peak" in (c.reason or ""))
check("...and the number written to the inverter is the guard's floor",
hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5))
rows = [r for r in c.checks() if "peak guard" in r["text"].lower()]
rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"])
@@ -1922,12 +1925,12 @@ asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
check("...once per episode, not once per cycle - a quarter of an hour of "
"identical cards is how an operator learns to dismiss the one that "
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
rows24 = [r for r in c24.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
check("the status page carries exactly one peak-guard row and it is NOT ok - "
"the money is going to be spent and a human is the only actuator left",
len(rows24) == 1 and not rows24[0]["ok"]
and "FLOOR-LIMITED" in rows24[0]["text"])
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
@@ -2479,17 +2482,23 @@ check("...and DOCS.md sends the reader to it rather than leaving the open "
# comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
def _row_scope(text):
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
return ("Home use still discharges below the reserve" in text
and "down to the cut-out level" in text)
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
"guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact "
"moment the replay above says the loop is still emptying the pack",
len(rows25) == 1
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
and "at its peak reserve" in rows25[0]["text"]
and _row_scope(rows25[0]["text"]))
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
# renders the first not-ok/not-warn row as the page headline, so before this the
# loudest element on the page was the same 550-character paragraph the row three
@@ -2501,26 +2510,19 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# The guard's own line, which the banner has always carried and which is not
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
# that is going over the ceiling.
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
"under 90 characters of it - and NOT the row's whole paragraph repeated "
"at the top of the page. A headline that is a paragraph is a headline "
"nobody reads, and this one is followed three lines later by the same "
"text verbatim",
present(_expl25)
and _pay25["level"] == "bad"
and _pay25["banner"].startswith("NOT READY")
and _expl25 in _pay25["banner"]
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
and "not a pack-wide reserve" not in _pay25["banner"]
and len(_pay25["banner"]) - len(_expl25) < 90)
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
"operator reads to find out what the floor does not bound",
len(rows25) == 1
and len(rows25[0]["text"]) > 300
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
"operator reads to find out what the reserve does not bound",
len(rows25) == 1 and _row_scope(rows25[0]["text"]))
_body25 = hass25.last_message("goodwe_peak_soc_floor")
@@ -5964,7 +5966,7 @@ check("...and the row still names WHERE the ceiling came from, which is the "
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "