UX-08 review: tighter notification texts, and two meaning fixes
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s

- not delivering: the body said control keeps running and then "start
  control"; clearing it takes stop then start, and it now says so.
- peak reserve: the "reserve not set" branch is really soc_floor None,
  i.e. an inverter's cut-out level missing; it now names that. The title
  is true on all three branches ("Battery stopped limiting the peak").
- no semicolon run-ons in bodies (STYLE.md + test); loop-bound, circulating,
  meter-frozen and multi-unit over-rating bodies rewritten to match.
- allowlist cap is now exact, so a shrink cannot leave room to regrow.
- DOCS names the settings behind "peak reserve" and "cut-out level";
  DECISIONS no longer states the HA anchor behaviour as fact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:18:55 +02:00
co-authored by Claude Opus 5.5
parent 50ccea3dd7
commit 7b65a3e69a
8 changed files with 61 additions and 42 deletions
+1 -1
View File
@@ -3917,7 +3917,7 @@ class Controller:
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
reserve_unset=(g.soc is not None
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ())).data())
+31 -26
View File
@@ -83,7 +83,7 @@ class Notice:
T_LOOP_BOUND = "Battery is not keeping up"
T_OUTPUT_CLAMP = "Power request too high"
T_PEAK_SATURATED = "Load too high for battery"
T_PEAK_RESERVE = "Battery at its peak reserve"
T_PEAK_RESERVE = "Battery stopped limiting the peak"
T_CIRCULATING = "Inverters working against each other"
T_NOT_DELIVERING = "{unit} is not delivering"
S_NOT_DELIVERING = "An inverter is not delivering"
@@ -109,8 +109,8 @@ 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 is not following the controller; short moments "
"are normal, repeated ones need a check.")
"The battery cannot give what the controller asks, which is "
"normal for a moment but needs a check if it keeps happening.")
@dataclass(frozen=True)
@@ -137,12 +137,13 @@ def output_clamp(clamps) -> Notice:
c = clamps[0]
who = f"For {c.unit}, the" if c.unit else "The"
others = [x.unit for x in clamps[1:] if x.unit]
also = (f"; {names(others)} {'was' if len(others) == 1 else 'were'} cut too"
if others else "")
running = (f"{names(others)} {'was' if len(others) == 1 else 'were'} cut "
f"too, and control is still running." if others
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"{who} controller asked for {watts(c.asked_w)}, above "
f"{LIMIT_WORDS.get(c.limit, LIMIT_WORDS['rating'])}, and sent "
f"{watts(c.sent_w)} instead{also}. Control is still running. "
f"{watts(c.sent_w)} instead. {running} "
f"This is a software fault, so please report it to your "
f"installer.")
@@ -157,20 +158,24 @@ def peak_saturated(ceiling_w) -> Notice:
f"the inverter.")
def peak_reserve(ceiling_w, *, soc_unread: bool, reserve_unset: bool,
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
"""SAFETY-24/25/32: the peak guard may not discharge any further.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
guard only, and normal home use goes on discharging below it.
`packs` names the batteries at their limit, only on a site with several.
`floor_unknown`: the floor the guard grades against is None, which at a
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
"""
if soc_unread:
why = "The battery level cannot be read"
act = "Switch off large loads, and check the battery level sensor."
elif reserve_unset:
why = "No peak reserve is set"
act = "Switch off large loads, and set a peak reserve in the settings."
elif floor_unknown:
why = "The cut-out level of a battery is not known"
act = ("Switch off large loads, and set each battery's cut-out level "
"in the settings.")
else:
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
@@ -179,7 +184,7 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, reserve_unset: bool,
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
f"{act} The reserve only limits peak shaving: normal home use "
f"{act} The reserve only applies to keeping the peak down: normal home use "
f"can still discharge the battery below it, down to its "
f"cut-out level.")
@@ -194,10 +199,10 @@ def circulating(discharging=(), charging=()) -> Notice:
what = ("One inverter was charging from another, and your meter "
"cannot see this.")
return Notice(ID_CIRCULATING, ALARM, T_CIRCULATING,
f"{what} Control is stopped and every inverter is set to "
f"0 W; if the grid power does not change now, switch the "
f"inverters off at the isolator. A restart does not clear "
f"this: start control on the add-on page once you know why.")
f"{what} Every inverter is set to 0 W: if the grid power does "
f"not change now, switch the inverters off at the isolator. "
f"Control stays stopped, even after a restart, until you "
f"start it on the add-on page once you know why.")
def not_delivering(unit: str, slug: str) -> Notice:
@@ -205,11 +210,11 @@ def not_delivering(unit: str, slug: str) -> Notice:
return Notice(
ID_NOT_DELIVERING.format(slug=slug), ALARM,
T_NOT_DELIVERING.format(unit=unit),
f"{unit} gives less power than it is asked for, so the site is short; "
f"nothing was changed and control keeps running. Check that {unit} is "
f"{unit} gives less power than it is asked for, but nothing was "
f"changed and control keeps running. Check that {unit} is "
f"on and connected, that its battery is not empty or cold, and that "
f"nothing else controls it. A restart does not clear this: start "
f"control on the add-on page once you know why.",
f"nothing else controls it. A restart does not clear this: once you "
f"know why, stop and start control on the add-on page.",
section=S_NOT_DELIVERING)
@@ -218,9 +223,9 @@ def meter_frozen(unchanged_s) -> Notice:
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
f"The meter has shown the same value for "
f"{duration(unchanged_s)} while the controller asks the "
f"battery for full power. Control is still running and still follows this "
f"reading. Check the meter; if it is stuck, stop control on "
f"the add-on page.")
f"battery for full power. Control is still running and still "
f"follows this reading. Check the meter, and if it is stuck, "
f"stop control on the add-on page.")
def samples() -> list:
@@ -232,11 +237,11 @@ def samples() -> list:
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
peak_saturated(5000),
peak_reserve(5000, soc_unread=False, reserve_unset=False),
peak_reserve(5000, soc_unread=False, reserve_unset=False,
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=True, reserve_unset=False),
peak_reserve(5000, soc_unread=False, reserve_unset=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
circulating(),
not_delivering("inverter-b", "inverter_b"),