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
+183 -300
View File
@@ -4551,27 +4551,24 @@ class Controller:
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False,
"text": "control loop: starting"})
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_starting()})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False,
"text": f"control loop: NO PASS FOR {_pass_age:.0f} s"
f" (layer 1 zeroes the inverter after "
f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
f"heartbeat)"})
out.append({"ok": False, "warn": False, "id": "controller",
"text": messages.row_controller_stalled(
_pass_age, FIRMWARE_INGEST_VETO_S)})
else:
out.append({"ok": True, "warn": False,
"text": f"control loop: last pass {_pass_age:.0f} s ago"})
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_ran(_pass_age)})
if self.p1_enabled:
age = self.p1.published_age_s
if self.p1.stale:
out.append({"ok": False, "warn": False,
"text": f"P1 meter ({o.get('meter_source')}): no reading for "
f"{age:.0f} s (limit {self.p1.max_age_s:.0f} s)"
+ (f" - last error: {self.p1.last_error}"
if self.p1.last_error else "")})
# The last error is on the add-on log's WARNING line (p1.py).
out.append({"ok": False, "warn": False, "id": "meter",
"text": messages.row_meter_stale(
age, self.p1.max_age_s)})
else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never folded
# into the age - see HomeWizardLocalSource.unchanged_s for why
@@ -4583,33 +4580,26 @@ class Controller:
# only homewizard_local has one, and p1_source is None until
# amain() builds the transport.
unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False,
"text": f"P1 meter ({o.get('meter_source')}): {self.p1.net_w:g} W, "
f"{age:.0f} s old, {self.p1.samples} telegrams, "
f"{self.p1.parse_errors} rejected"
+ (f", measurement unchanged for {unchanged:.0f} s"
if unchanged is not None else "")})
out.append({"ok": True, "warn": False, "id": "meter",
"text": messages.row_meter(
self.p1.net_w, age, self.p1.parse_errors,
unchanged)})
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
# a detector nobody can see is armed is a detector nobody trusts
# when it fires. ok=False/warn=False so the banner goes red - this
# is an alarm, and the banner is the loudest surface we own. It
# changes NOTHING about control; see check_meter_frozen().
if self.freeze_wd.active:
out.append({"ok": False, "warn": False,
"text": "METER MAY BE FROZEN - " + self.freeze_wd.reason
+ ". The loop is STILL FOLLOWING it and is "
"still running. Check the meter. Held here "
"for a quarter of an hour this sets the "
"month's billed capacity peak."})
# The figures are in `freeze_wd.reason`, on the log and the
# event list (check_meter_frozen()).
out.append({"ok": False, "warn": False, "id": "meter_frozen",
"text": messages.row_meter_frozen()})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED on "
f"meter_source {o.get('meter_source')} - only "
f"homewizard_local sees whether the meter's own "
f"document changes"})
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off("source")})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
@@ -4619,12 +4609,11 @@ class Controller:
# and an operator does not need to learn two vocabularies for
# it. What differs is only which term is missing: there,
# `unchanged_s`; here, a rail the command could ever stand at.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED - "
+ rail_unreachable(
self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))})
_no_rating = (not self.site_max_w
or not is_commandable(self.site_max_w))
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off(
"rating" if _no_rating else "range")})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included. This row used to interpolate
@@ -4643,17 +4632,12 @@ class Controller:
# reads `min` and `max` independently and returns None for
# whichever attribute is missing or unparseable - so this
# renders whatever is there rather than formatting a None.
_cap = ("" if _rail >= rail_w(self.site_max_w)
else " - below max_w because "
+ "; ".join(
f"{inv.setpoint_entity or inv.name} accepts "
f"only {inv.dev_min}...{inv.dev_max} W"
for inv in self.inverters))
out.append({"ok": True, "warn": False,
"text": f"freeze detector: armed (alerts after "
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
f"while the command is over "
f"{_rail:.0f} W{_cap})"})
# UX-08: "the inverter's own limit" says the device set it; which
# inverter and its range are DOCS and the settings, not entity ids.
out.append({"ok": True, "warn": False, "id": "freeze_check",
"text": messages.row_freeze_on(
self.freeze_wd.cfg.hold_s, _rail,
own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. The month's billed peak, and - the load-bearing half -
# a DIFFERENT row when the store could not answer. "no peak yet" is
@@ -4673,8 +4657,8 @@ class Controller:
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
+ f"; {self.peak.recorded} quarter(s) banked"
+ f"; on start: {self.peak.restore_state}"})
+ f", {self.peak.recorded} quarter(s) banked"
+ f", on start: {self.peak.restore_state}"})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
@@ -4684,12 +4668,12 @@ class Controller:
# decision that has not been taken.
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True,
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": "peak guard: no decision yet - the first "
"control cycle has not run"})
elif _pg.saturated:
out.append({"ok": False, "warn": False,
"text": "PEAK GUARD SATURATED - " + _pg.reason})
out.append({"ok": False, "warn": False, "id": "peak_guard",
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited:
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
@@ -4713,22 +4697,27 @@ class Controller:
# the whole sentence, because the row is where an operator
# reads what the floor does and does not bound. Shortening the
# ROW instead would be weakening AC 3.
out.append({"ok": False, "warn": False,
"banner": _pg.explain()
+ " - and peak_soc_floor bounds THE PEAK "
"GUARD ONLY (SAFETY-25, see below)",
"text": _pg.explain()
+ ". NOTE: peak_soc_floor bounds THE PEAK "
"GUARD ONLY - it is not a pack-wide "
"reserve, and ordinary self-consumption "
"has no SoC floor, so the battery may "
"keep discharging below this level down "
"to the BMS cut-out (SAFETY-25)"})
# UX-08: the same three branches, and the same pack naming, as
# check_peak_floor_limited()'s notification. The figures stay in
# `_pg.reason`, on the log and the event list.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs,
_pg.soc_floor)
if why is not None]
out.append({"ok": False, "warn": False, "id": "peak_guard",
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ()))})
elif _pg.blind:
out.append({"ok": False, "warn": True, "text": _pg.explain()})
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": _pg.explain()})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"text": _pg.explain()})
"id": "peak_guard", "text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
@@ -4737,11 +4726,8 @@ class Controller:
# leave the one install that is unprotected showing nothing at all.
# Same answer, same reason, as the freeze detector's NOT COVERED row
# a few lines up.
out.append({"ok": False, "warn": True,
"text": f"peak guard: NOT COVERED on meter_source "
f"{o.get('meter_source')} - the quarter-hour "
f"average is measured from P1 telegrams and this "
f"path has none, so a runaway cannot be capped"})
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_off()})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on
# a site with two packs is a page that shows one pack and calls it the
@@ -4749,17 +4735,21 @@ class Controller:
# that is what an existing install reads.
rows = []
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
rows.append((f"battery SoC{tag}", inv.soc, inv.soc_entity))
rows.append((f"battery power{tag}", inv.batt, inv.batt_entity))
unit = None if len(self.inverters) == 1 else inv.name
rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
inv.batt_entity))
if not self.p1_enabled:
# In P1 mode the check above replaces this one; leaving both in
# would report "no entity configured" for a meter_entity that is
# correctly unused, i.e. a permanent false NOT READY.
rows.insert(0, ("grid power", self.grid, o.get("meter_entity")))
for label, value, entity in rows:
rows.insert(0, (messages.LABEL_GRID, None, self.grid,
o.get("meter_entity")))
# UX-08: the rows name the setting, never the entity id (STYLE.md).
for label, unit, value, entity in rows:
if not entity:
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unset(label, unit)})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
@@ -4783,32 +4773,25 @@ class Controller:
# pre-empting the answer. Same reason the wording is not shared
# with `freshness._grade()`: that one is a log line about a
# BEHAVIOUR, this one is a banner about an ENTITY.
out.append({"ok": False, "warn": False,
"text": f"{label}: no usable value this cycle from "
f"{entity} - and this row cannot tell which "
f"of two things that is: the entity is "
f"missing or not numeric, or it is "
f"publishing a number that this controller "
f"is withholding (a freshness gate does "
f"exactly that, and names itself in a row "
f"of its own). Confirm the entity in "
f"Developer Tools before replacing it"})
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unusable(label, unit)})
else:
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
out.append({"ok": True, "warn": False, "id": "sensor",
"text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
sp = inv.setpoint_entity
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
unit = None if len(self.inverters) == 1 else inv.name
if not sp:
out.append({"ok": False, "warn": False,
"text": f"setpoint entity not configured{tag}"})
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_unset(unit)})
elif inv.dev_max is None:
out.append({"ok": False, "warn": False,
"text": f"setpoint entity {sp} not found"})
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_missing(unit)})
else:
out.append({"ok": True, "warn": False,
"text": f"setpoint {sp} (range {inv.dev_min:g}…"
f"{inv.dev_max:g} W)"})
out.append({"ok": True, "warn": False, "id": "control",
"text": messages.row_control(inv.dev_min,
inv.dev_max, unit)})
# DEV-02. Whether this controller can still put a number on each
# inverter, and it is reported in the ONE state that matters: a unit
@@ -4825,28 +4808,35 @@ class Controller:
# evidence with it. Reported only when there is something to report:
# unlike the freeze detector this guard has no "not covered" state, it
# runs on every write on every transport.
if self.clamp_wd.active and self.clamp_wd.bug:
out.append({"ok": False, "warn": False,
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.active:
# UX-07 item 1 / UX-08: each count is shown under ITS alarm's name -
# over-rating cuts as "Power request too high", integrator-bound cycles
# as "Battery is not keeping up" - never the two summed under one.
_cw = self.clamp_wd
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
"banner": messages.banner_power_too_high(),
"text": messages.row_power_too_high(_cw.facts,
_cw.over_rating)})
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True,
"text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.engagements:
out.append({"ok": False, "warn": True,
"text": f"output clamp: not engaged now, but it has "
f"engaged {self.clamp_wd.engagements} time(s) in "
f"{self.clamp_wd.episodes} episode(s) since "
f"start - see the event log"})
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(_cw.bound)})
else:
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound)})
for inv in self.inverters:
if inv.last_write_ok is False:
tag = "" if len(self.inverters) == 1 else f" ({inv.name})"
out.append({"ok": False, "warn": False,
"text": f"last write to the inverter{tag} was "
f"REJECTED - check the log"})
out.append({"ok": False, "warn": False, "id": "write",
"text": messages.row_write_refused(
None if len(self.inverters) == 1
else inv.name)})
# SAFETY-02. Reported in both states: a watchdog nobody can see is armed
# is a watchdog nobody trusts when it fires.
@@ -4861,7 +4851,7 @@ class Controller:
# inverter's own power sensor. `sign_wd.against` is the same string the
# trip sentence uses, so the two cannot drift apart.
for inv in self.inverters:
tag = "" if len(self.inverters) == 1 else f" [{inv.name}]"
unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
# shipped roster of one the two sentences below are the ones that
# have always been on the page, word for word - AC 8 is about what
@@ -4870,25 +4860,16 @@ class Controller:
# somebody has learned to recognise. At N>=2 the noun is load-
# bearing: the row would otherwise send them to look at P1 for a
# fault that is visible only on one inverter's own power sensor.
vs = ("" if len(self.inverters) == 1
else f" against {inv.sign_wd.against}")
# UX-08: at N>=2 `unit` is set, and the row says "its own power".
# The figures stay in `sign_wd.reason`, on the log and event list.
if inv.sign_wd.tripped:
what = ("the meter" if len(self.inverters) == 1
else inv.sign_wd.against)
out.append({"ok": False, "warn": False,
"text": f"SIGN-DISAGREEMENT FAILSAFE{tag} - "
+ inv.sign_wd.reason
+ f". The inverter is held at 0 W. Find out why the "
f"command opposed {what} before starting control "
"again; starting clears this. Restarting the "
"add-on does NOT clear it."})
out.append({"ok": False, "warn": False, "id": "direction",
"text": messages.row_direction_trip(unit)})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"sign watchdog{tag}: armed{vs} (trips after "
f"{inv.sign_wd.cfg.hold_s:.0f} s opposed, both over "
f"{inv.sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {held:.0f} s now" if held else "")})
out.append({"ok": True, "warn": False, "id": "direction",
"text": messages.row_direction_check(
held, inv.sign_wd.cfg.hold_s, unit)})
# --- SAFETY-30: the SITE row -----------------------------------------
# ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
@@ -4898,25 +4879,13 @@ class Controller:
# operator learns to skim the list.
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "SITE SIGN-DISAGREEMENT FAILSAFE - "
+ self.site_sign_wd.reason
+ ". EVERY inverter is held at 0 W. This is the "
"SUM of the commands against the meter, so it is "
"not one inverter's fault to find - look at the "
"loop, the integrator and whatever won the "
"arbitration. Starting control clears this; "
"restarting the add-on does NOT."})
out.append({"ok": False, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_trip()})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"site sign watchdog: armed against the sum of "
f"the commands (trips after "
f"{self.site_sign_wd.cfg.hold_s:.0f} s opposed, "
f"both over "
f"{self.site_sign_wd.cfg.min_w:.0f} W)"
+ (f" - opposed for {_sheld:.0f} s now"
if _sheld else "")})
out.append({"ok": True, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_check(
_sheld, self.site_sign_wd.cfg.hold_s)})
# --- SAFETY-31 -----------------------------------------------------
# ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
@@ -4924,28 +4893,20 @@ class Controller:
# here would invite the operator to clear "the other one".
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False,
"text": "CIRCULATING POWER - " + self.circ_wd.reason
+ ". EVERY inverter is held at 0 W. The meter "
"cannot see this fault, which is why nothing "
"else reported it. Starting control clears "
"this; restarting the add-on does NOT, and "
"neither does restarting Home Assistant."})
out.append({"ok": False, "warn": False, "id": "circulating",
"text": messages.row_circulating_trip()})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False,
"text": "circulating power: n/a at one inverter - "
"sum(|Bi|) - |sum(Bi)| is identically zero"})
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
"Circulating power check")})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": "circulation watchdog: armed (trips after "
f"{self.circ_wd.cfg.hold_s:.0f} s over "
f"{self.circ_wd.cfg.min_w:.0f} W)"
+ (f" - now {_circ:.0f} W" if _circ else "")
+ (f", opposed for {held:.0f} s" if held else "")})
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_circulating_check(
_circ, held, self.circ_wd.cfg.hold_s)})
# --- SAFETY-42 -------------------------------------------------------
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
@@ -4958,42 +4919,26 @@ class Controller:
# caution is what a fault nobody is acting on actually is.
if len(self.inverters) > 1:
for inv in self.inverters:
tag = f" ({inv.name})"
if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True,
"text": f"NOT DELIVERING{tag} - "
+ inv.delivery_wd.reason
+ ". Nothing has been commanded differently and "
"the rest of the roster is still regulating, "
"so the site is short by this unit's share. "
"Starting control clears this row; restarting "
"the add-on does NOT, and neither does "
"restarting Home Assistant."})
out.append({"ok": False, "warn": True, "id": "delivery",
"text": messages.row_not_delivering(inv.name)})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"delivery watchdog{tag}: armed (reports after "
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
f"its share by over "
f"{self.tuning.saturation_w:.0f} W, with a "
f"sibling that is not)"
+ (f" - short for {_dheld:.0f} s now"
if _dheld else "")})
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_delivery_check(
inv.name, _dheld,
inv.delivery_wd.cfg.hold_s)})
else:
# AC 5, and the same honesty `circulating power: n/a` uses one block
# up. At one inverter the reduction this check is has no proper
# subset to find, for EVERY input, so "armed" would be a claim to be
# watching something that cannot happen.
out.append({"ok": True, "warn": False,
"text": "delivery watchdog: n/a at one inverter - a unit "
"short of its share is indistinguishable from a "
"plant that cannot deliver until there is a "
"sibling to compare it with"})
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_one_inverter("Delivery check")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True,
"text": "no e-stop fitted: if this host dies the battery stays "
"latched at its last command"})
out.append({"ok": False, "warn": True, "id": "estop",
"text": messages.row_no_estop()})
else:
out.append({"ok": True, "warn": False, "text": "e-stop fitted"})
@@ -5017,105 +4962,54 @@ class Controller:
# control path. The reason comes from `pv.watts()` rather than being
# re-derived here, so the page cannot disagree with the reading.
#
# --- TEL-12: AND AT A ROSTER OF ONE THESE TWO SENTENCES ARE 0.11.0's --
# ⚠️ THE `len(...) == 1` BRANCH IS 0.11.0's CODE UNCHANGED, DOWN TO THE
# f-string, so an install that set `pv_entity` reads the row it already
# reads. That is an ACCEPTANCE CRITERION rather than a courtesy, and it
# is graded across trees by `tools/tel11_pv_trace.py` rather than argued
# here: `pv.total()` passes a single entity's `why` through verbatim for
# exactly this reason. Multi-entity sentences are separate BECAUSE they
# have to say something the single-entity ones never could.
# TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08
# replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so
# a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows
# these rows as changed telemetry; the control documents stay equal.
roster = self.pv_roster
if roster:
# TEL-14: each flipped entity is marked where its watts are printed;
# an unflipped one gets "", so 0.12.0's sentences are unchanged.
# UX-08: sources are numbered in the order of the settings list,
# never named by entity id (STYLE.md). The per-source figures and
# units are what `pv.total()` returns; they stay in `pv_parts`.
flip = pv_read.inverted(self.o)
def inv(ent):
return " [sign inverted]" if ent in flip else ""
if len(roster) == 1:
if self.pv_w is None:
out.append({"ok": False, "warn": True,
"text": f"PV production: no reading from "
f"{roster[0]} - {self.pv_why}"})
else:
# habit 14: the value sits inside its own literal text, in
# its own slot, beside the entity it was read from and the
# unit it was read in - so a transposition of any two of the
# three is visible in the sentence rather than merely
# present in it.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W "
f"from {roster[0]} (reported "
f"in {self.pv_why})" + inv(roster[0])})
n_src = len(roster)
if self.pv_w is None and n_src == 1:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_none(self.pv_why)})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS. `pv.total()`'s note is the argument; this is
# where the operator meets it. The partial information is NOT
# thrown away - every entity that reported is named with its
# watts and every one that did not is named with its reason -
# because this surface is read LIVE by a human who wants the
# diagnostic, whereas the MQTT row is read back COLD a week
# later by an attribution that would believe a smaller number.
# Different reader, different instant, different rule.
out.append({"ok": False, "warn": True,
"text": "PV production: NO SITE TOTAL - "
+ self.pv_why + ". Reporting: "
+ (", ".join(
f"{ent} {watt:.0f} W" + inv(ent)
for ent, watt, _y in self.pv_parts
if watt is not None) or "nothing")})
# THOSE WORDS - and which sources are missing, by position.
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_partial(
[i + 1 for i, (_e, watt, _y)
in enumerate(self.pv_parts) if watt is None],
n_src)})
else:
# habit 14 at a roster: the total in its own slot, then EVERY
# contributor in its own slot with its own watts and its own
# unit. A sum that disagrees with the addends it is printed
# beside is visible in the sentence.
out.append({"ok": True, "warn": False,
"text": f"PV production: {self.pv_w:.0f} W from "
f"{len(self.pv_parts)} entities ("
+ ", ".join(
f"{ent} {watt:.0f} W in {why}"
+ inv(ent)
for ent, watt, why in self.pv_parts)
+ ")"})
out.append({"ok": True, "warn": False, "id": "pv",
"text": messages.row_pv(
self.pv_w, n_src,
n_src == 1 and roster[0] in flip)})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
# right rule (its docstring says why) and is also the rule an
# operator upgrading from 0.11.0 is most likely to trip over: they
# add all their inverters to the new field and the old key is left
# standing, doing nothing. A replacement nobody is told about is the
# "one of which silently wins" `config.yaml` refuses elsewhere - and
# refusing to START over a telemetry key would be observability with
# a foot in the control path, so this is a CAUTION and never a `bad`
# row.
# rule an operator upgrading from 0.11.0 is most likely to trip
# over. A CAUTION and never a `bad` row: telemetry must not stop
# control.
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True,
"text": "PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the "
f"{len(roster)} entities in the "
"list are what is summed. Clear "
"`pv_entity` to remove this caution"})
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_single_ignored()})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for ent, watt, _y in self.pv_parts:
for i, (ent, watt, _y) in enumerate(self.pv_parts):
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True,
"text": f"PV production: {ent} reads "
f"{watt:.0f} W - far below zero, so "
f"its sign is probably reversed. If "
f"it is producing now, "
+ ("REMOVE it from "
"`pv_invert_entities`"
if ent in flip else
"ADD it to `pv_invert_entities`")
+ " in the Configuration tab"})
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_negative(
i + 1, n_src, watt, ent in flip)})
_inv_list = [str(x or "").strip()
for x in (self.o.get("pv_invert_entities") or [])]
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True,
"text": f"PV production: `pv_invert_entities` "
f"names {ent}, which is not a PV entity in "
f"use, so it flips nothing - check the "
f"spelling against `pv_entities`"})
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_stray(
_inv_list.index(ent) + 1)})
return out
def status_payload(self) -> dict:
@@ -5145,9 +5039,7 @@ class Controller:
# is the fault the operator has NO OTHER WAY of learning about: a
# sign disagreement is visible at the meter and on every dashboard
# in the house, and circulating power is visible nowhere at all.
level, banner = "bad", ("CIRCULATING POWER — the inverters were "
"pushing against each other. Site held at "
"0 W.")
level, banner = "bad", messages.banner_circulating()
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
@@ -5156,28 +5048,23 @@ class Controller:
# inverter is parked and the fault is in what the site was asking
# for, not in one device - naming a unit here would send an operator
# to swap the wrong box.
level, banner = "bad", ("FAILSAFE — the site's total command "
"opposed the meter. Every inverter held at "
"0 W.")
level, banner = "bad", messages.banner_site_direction()
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "NOT READY": nothing is missing, something went wrong.
# ⚠️ THE SENTENCE IS UNCHANGED AT A ROSTER OF ONE, where "the meter"
# is what the unit was graded against. At N>=2 it is that unit's own
# power, so the banner says so - see checks() on why the noun is
# load-bearing rather than cosmetic.
level, banner = "bad", (
"FAILSAFE — the command opposed the meter. "
"Inverter held at 0 W." if len(self.inverters) == 1 else
"FAILSAFE — an inverter's command opposed its own measured "
"power. That inverter held at 0 W.")
# Outranks "Not ready": nothing is missing, something went wrong.
# At a roster of one the unit was graded against the meter; at N>=2
# against its own power, so the banner says so - see checks() on
# why the noun is load-bearing rather than cosmetic.
level, banner = "bad", messages.banner_direction(
len(self.inverters) > 1)
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
# need to say more to an operator reading the checks list than fits
# in the one line at the top of the page - SAFETY-25's floor-limited
# row is the case that forced this - and a headline that is a full
# paragraph, repeated verbatim by the row underneath it, is a
# headline nobody reads. Rows without the key are unaffected.
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
# headline nobody reads. UX-08: without the key, the banner takes
# the row's first sentence, so it stays a headline.
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif any(i.delivery_wd.tripped for i in self.inverters):
@@ -5194,12 +5081,8 @@ class Controller:
# precisely the shape this whole card exists to remove. It sits
# BELOW "Stopped" because a stopped site is the more urgent fact and
# the banner carries one sentence.
_nd = ", ".join(i.name for i in self.inverters
if i.delivery_wd.tripped)
level, banner = "warn", (
f"NOT DELIVERING — {_nd} is standing at a share it is not "
f"producing. The site is still running and short by it; "
f"nothing has been commanded differently.")
level, banner = "warn", messages.banner_not_delivering(
[i.name for i in self.inverters if i.delivery_wd.tripped])
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for