UX-08 phase 2a: short, plain notifications, and a ratchet for the rest
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s

Every HA notification the add-on raises now has a title of at most 5
everyday words and a one-sentence body; a red alarm may use three so no
action is lost. The texts live in app/messages.py, the rule in STYLE.md,
and the detail that moved out in DOCS.md "Notifications", which each body
links to. No notification_id, trigger or cadence changed.

test_messages.py enforces the rule on every notification, fails any
notification worded outside app/messages.py, and ratchets the kinds not yet
rewritten (rows, banners, refusals, logs, options) through a shrinking
allowlist of 200. Six tests re-pinned from prose to meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
glenn schrooyen
2026-09-27 01:00:54 +02:00
co-authored by Claude Opus 5.5
parent 2503de124e
commit 50ccea3dd7
13 changed files with 1170 additions and 185 deletions
+23 -14
View File
@@ -114,6 +114,7 @@ from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
from app import messages
from app import peakguard
from named_absence import first, given, nth, present
@@ -2522,13 +2523,21 @@ check("...and shortening the banner did NOT shorten the row: the full AC 3 "
and "BMS cut-out" in rows25[0]["text"])
_body25 = hass25.last_message("goodwe_peak_soc_floor")
def _says_scope(body):
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
guard only, and home use goes on discharging below it to the cut-out."""
return ("The reserve only limits peak shaving" in body
and "normal home use can still discharge the battery below it" in body
and "down to its cut-out level" in body)
print(f" notification body tail: ...{_body25[-180:]}")
check("...and so does the persistent notification, which outlives the page and "
"is the thing an operator reads on a phone at 19:00. It names what has "
"stopped - the guard buying a lower bill - and what has not",
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
and "not a" in _body25 and "pack-wide reserve" in _body25
and "BMS cut-out" in _body25)
_says_scope(_body25))
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
@@ -4332,8 +4341,8 @@ check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
"the site, and names only that one. 'the battery is at its floor' on a "
"site with two batteries is an instruction to go and look at both, and "
"the wrong one is the one that is fine",
"SAFETY-32" in _body32 and "inverter-b" in _body32
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
"The battery of inverter-b is at its peak reserve" in _body32
and "inverter-a" not in _body32)
# The mirror again, on the SoC axis: swap which pack is empty and the name
# swaps. A hardwired `self.inverters[1].name` passes the line above.
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
@@ -4341,8 +4350,8 @@ _body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
check("...and with the SAME two readings swapped between the packs it names "
"inverter-a instead - so the sentence follows the measurement rather "
"than a position in the roster",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
"The battery of inverter-a is at its peak reserve" in _body32b
and "inverter-b" not in _body32b)
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
@@ -4366,13 +4375,14 @@ async def _notify32_one():
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32_one())
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
"byte for byte the two-pack message minus the name (UX-08). There is "
"one battery, naming it is noise, and this alarm reaches every existing "
"install",
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
and _body32one == _body32.split("\n\nSAFETY-32")[0]
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
"inverter-" not in _body32one
and _body32one == _body32.replace("The battery of inverter-b is",
"The battery is")
and _says_scope(_body32one))
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
@@ -5604,8 +5614,7 @@ MUT.append(("rule 8's refusal naming a hardwired inverter",
# 21. the notification naming a fixed roster position rather than the pack that
# is actually at its floor.
MUT.append(("the per-pack notification naming a fixed roster position",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
"inverter-a" in _body32b and "inverter-b" not in _body32b,
"part 12h: the same two readings swapped between the packs"))
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------