UX-08 phase 2b: short, plain status rows and page banners
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:
@@ -358,6 +358,10 @@ class ClampWatchdog:
|
||||
def __init__(self):
|
||||
self.active = False
|
||||
self.engagements = 0 # every engagement, never deduplicated
|
||||
# UX-08: the same count split by alarm type, so the status page names
|
||||
# each with its own alarm's title after the episode clears.
|
||||
self.over_rating = 0 # bug=True: "Power request too high"
|
||||
self.bound = 0 # bug=False: "Battery is not keeping up"
|
||||
self.episodes = 0 # rising edges
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
@@ -399,6 +403,10 @@ class ClampWatchdog:
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
if bug:
|
||||
self.over_rating += 1
|
||||
else:
|
||||
self.bound += 1
|
||||
self.clean_cycles = 0
|
||||
self.run_len += 1
|
||||
rising = not self.active
|
||||
|
||||
+183
-300
@@ -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
|
||||
|
||||
@@ -228,6 +228,416 @@ def meter_frozen(unchanged_s) -> Notice:
|
||||
f"stop control on the add-on page.")
|
||||
|
||||
|
||||
# --- the status page (UX-08 phase 2b) ----------------------------------------
|
||||
# The rows of the Commissioning list and the banner at the top of the add-on
|
||||
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
|
||||
# these decide WHAT it says. A row is one sentence of at most 15 words; a red
|
||||
# row may use up to 3 sentences and 30 words. A banner is at most 12 words.
|
||||
# Rows never name a Home Assistant entity: they name the setting in words.
|
||||
# DOCS.md, "Status page", explains every row.
|
||||
|
||||
NOT_READY = "Not ready"
|
||||
DOCS_STATUS = "Status page"
|
||||
|
||||
|
||||
def signed_watts(value) -> str:
|
||||
"""`-1200` -> `-1 200 W`: like `watts()`, but the sign is kept."""
|
||||
return f"{float(value):,.0f} W".replace(",", " ")
|
||||
|
||||
|
||||
def seconds(value) -> str:
|
||||
"""Always in seconds, for a live count set against its limit."""
|
||||
n = f"{float(value):.0f}"
|
||||
return f"{n} second" if n == "1" else f"{n} seconds"
|
||||
|
||||
|
||||
def times(n) -> str:
|
||||
return "1 time" if int(n) == 1 else f"{int(n)} times"
|
||||
|
||||
|
||||
def for_unit(unit) -> str:
|
||||
"""` for inverter-b` on a site with several inverters, else nothing."""
|
||||
return f" for {unit}" if unit else ""
|
||||
|
||||
|
||||
def headline(text: str) -> str:
|
||||
"""The first sentence of a row, for the banner when the row has no own."""
|
||||
first = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text.strip(), maxsplit=1)[0]
|
||||
return first.rstrip(".")
|
||||
|
||||
|
||||
# Banners (the headline at the top of the page).
|
||||
def banner_not_ready(row: dict) -> str:
|
||||
return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}"
|
||||
|
||||
|
||||
def banner_circulating() -> str:
|
||||
return "Inverters worked against each other. Every inverter is set to 0 W."
|
||||
|
||||
|
||||
def banner_site_direction() -> str:
|
||||
return "Total command and meter point opposite ways. All inverters at 0 W."
|
||||
|
||||
|
||||
def banner_direction(several: bool) -> str:
|
||||
if several:
|
||||
return "Inverter and own power point opposite ways. It is at 0 W."
|
||||
return "Command and meter point opposite ways. Inverter set to 0 W."
|
||||
|
||||
|
||||
def banner_not_delivering(units) -> str:
|
||||
units = list(units)
|
||||
verb = "are" if len(units) > 1 else "is"
|
||||
return f"{names(units)} {verb} not delivering. Control keeps running."
|
||||
|
||||
|
||||
# Rows: the controller and the meter.
|
||||
def row_controller_starting() -> str:
|
||||
return "Controller: starting"
|
||||
|
||||
|
||||
def row_controller_ran(age_s) -> str:
|
||||
return f"Controller: last ran {seconds(age_s)} ago"
|
||||
|
||||
|
||||
def row_controller_stalled(age_s, veto_s) -> str:
|
||||
return (f"Controller has not run for {seconds(age_s)}. The control board "
|
||||
f"sets the inverter to 0 W after {seconds(veto_s)}.")
|
||||
|
||||
|
||||
def row_meter_stale(age_s, limit_s) -> str:
|
||||
return (f"Meter: no reading for {seconds(age_s)}, limit "
|
||||
f"{seconds(limit_s)}. The add-on log shows why.")
|
||||
|
||||
|
||||
def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str:
|
||||
return (f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old"
|
||||
+ (f", {rejected} readings refused" if rejected else "")
|
||||
+ ("" if unchanged_s is None
|
||||
else f", unchanged for {duration(unchanged_s)}"))
|
||||
|
||||
|
||||
def row_meter_frozen() -> str:
|
||||
return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, "
|
||||
f"and stop control if it is stuck.")
|
||||
|
||||
|
||||
FREEZE_OFF = {
|
||||
"source": "it only works with the HomeWizard local meter source",
|
||||
"rating": "the inverter rating is not set",
|
||||
"range": "the inverter reports no usable power range",
|
||||
}
|
||||
|
||||
|
||||
def row_freeze_off(why: str) -> str:
|
||||
"""`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run."""
|
||||
return f"Stuck-meter check: off, {FREEZE_OFF[why]}"
|
||||
|
||||
|
||||
def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str:
|
||||
return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged "
|
||||
f"above {watts(rail_w)}"
|
||||
+ (", the inverter's own limit" if own_limit else ""))
|
||||
|
||||
|
||||
# Rows: the peak guard.
|
||||
def row_peak_saturated(ceiling_w) -> str:
|
||||
return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the "
|
||||
f"{watts(ceiling_w)} peak limit.")
|
||||
|
||||
|
||||
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> str:
|
||||
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
|
||||
if soc_unread:
|
||||
why = "Battery level cannot be read"
|
||||
elif floor_unknown:
|
||||
why = "A battery cut-out level is not set"
|
||||
else:
|
||||
why = (f"{names(packs)} battery at its peak reserve" if packs
|
||||
else "Battery at its peak reserve")
|
||||
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
|
||||
f"use still discharges below the reserve, down to the cut-out level.")
|
||||
|
||||
|
||||
def row_peak_guard_off() -> str:
|
||||
"""No meter source: the 15-minute average cannot be measured."""
|
||||
return ("Peak guard: off, it needs a meter source instead of the grid "
|
||||
"power sensor")
|
||||
|
||||
|
||||
def banner_peak_reserve() -> str:
|
||||
return T_PEAK_RESERVE
|
||||
|
||||
|
||||
# Rows: the sensors and the inverter control.
|
||||
LABEL_SOC = "Battery level"
|
||||
LABEL_BATTERY = "Battery power"
|
||||
LABEL_GRID = "Grid power"
|
||||
|
||||
|
||||
def row_sensor(label, value, unit=None) -> str:
|
||||
shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \
|
||||
else signed_watts(value)
|
||||
return f"{label}{for_unit(unit)}: {shown}"
|
||||
|
||||
|
||||
def row_sensor_unset(label, unit=None) -> str:
|
||||
return f"{label}{for_unit(unit)}: no sensor set in the settings"
|
||||
|
||||
|
||||
def row_sensor_unusable(label, unit=None) -> str:
|
||||
"""SAFETY-56: it may be missing OR held back as stale; say both."""
|
||||
return (f"{label}{for_unit(unit)}: no usable reading. The sensor may be "
|
||||
f"missing, or its reading is held back as too old. Check it in "
|
||||
f"Developer Tools before replacing it.")
|
||||
|
||||
|
||||
def row_control_unset(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not set in the settings"
|
||||
|
||||
|
||||
def row_control_missing(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not found in Home Assistant"
|
||||
|
||||
|
||||
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
|
||||
|
||||
|
||||
# Rows: the two loop alarms. The names are the notification titles, so the
|
||||
# row and the card always call the same thing by the same name (UX-07 item 1).
|
||||
def row_power_too_high(clamps, count) -> str:
|
||||
c = clamps[0] if clamps else None
|
||||
what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, sent "
|
||||
f"{watts(c.sent_w)}. Cut back" if c else "cut back")
|
||||
return (f"{T_OUTPUT_CLAMP}: {what} {times(count)} since start. This is a "
|
||||
f"software fault, so please report it.")
|
||||
|
||||
|
||||
def banner_power_too_high() -> str:
|
||||
return T_OUTPUT_CLAMP
|
||||
|
||||
|
||||
def row_not_keeping_up(count) -> str:
|
||||
return f"{T_LOOP_BOUND}: {times(count)} since start"
|
||||
|
||||
|
||||
def row_before(title, count) -> str:
|
||||
"""After an episode clears: how often it happened, so the evidence stays."""
|
||||
return f"{title}: {times(count)} since start, not now"
|
||||
|
||||
|
||||
def row_write_refused(unit=None) -> str:
|
||||
return (f"{unit or 'The inverter'} refused the last command. Check the "
|
||||
f"add-on log.")
|
||||
|
||||
|
||||
# Rows: the direction checks (SAFETY-02/30).
|
||||
def row_direction_trip(unit=None) -> str:
|
||||
"""Per inverter. `unit` only at several inverters, graded on its own power."""
|
||||
what = (f"{unit}: command and its own power point opposite ways, so it is"
|
||||
if unit else "Command and meter point opposite ways, so the "
|
||||
"inverter is")
|
||||
return (f"{what} set to 0 W. Find out why, then start control. A restart "
|
||||
f"does not clear this.")
|
||||
|
||||
|
||||
def row_direction_check(held_s, hold_s, unit=None) -> str:
|
||||
who = (f"Direction check for {unit}, against its own power" if unit
|
||||
else "Direction check")
|
||||
if held_s:
|
||||
return f"{who}: opposite for {held_s:.0f} of {seconds(hold_s)}"
|
||||
return f"{who}: on"
|
||||
|
||||
|
||||
def row_site_direction_trip() -> str:
|
||||
return ("Total command and meter point opposite ways, so every inverter is "
|
||||
"set to 0 W. The cause is the total, not one inverter. Only starting "
|
||||
"control clears this.")
|
||||
|
||||
|
||||
def row_site_direction_check(held_s, hold_s) -> str:
|
||||
if held_s:
|
||||
return (f"Site direction check: opposite for {held_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return "Site direction check: on, against the meter"
|
||||
|
||||
|
||||
# Rows: circulating power (SAFETY-31) and delivery (SAFETY-42).
|
||||
def row_circulating_trip() -> str:
|
||||
return ("Inverters worked against each other, so every inverter is set to "
|
||||
"0 W. The meter cannot see this. Only starting control clears this.")
|
||||
|
||||
|
||||
def row_circulating_check(now_w, held_s, hold_s) -> str:
|
||||
return ("Circulating power check: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else "")
|
||||
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
|
||||
|
||||
|
||||
def row_one_inverter(check: str) -> str:
|
||||
return f"{check}: not needed with one inverter"
|
||||
|
||||
|
||||
def row_not_delivering(unit) -> str:
|
||||
return (f"{unit} is not delivering, and only stopping and starting control "
|
||||
f"clears this")
|
||||
|
||||
|
||||
def row_delivery_check(unit, short_s, hold_s) -> str:
|
||||
if short_s:
|
||||
return (f"Delivery check for {unit}: short for {short_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return f"Delivery check for {unit}: on"
|
||||
|
||||
|
||||
def row_no_estop() -> str:
|
||||
return ("No emergency stop fitted: if this computer fails, the battery "
|
||||
"keeps its last command")
|
||||
|
||||
|
||||
# Rows: solar production (TEL-11/12/14). Sources are numbered in the order of
|
||||
# the solar sensor list in the settings.
|
||||
def source(n, of) -> str:
|
||||
return "the sensor" if of == 1 else f"source {n}"
|
||||
|
||||
|
||||
def pv_problem(why: str) -> str:
|
||||
"""`pv.watts()`'s reason, in plain words."""
|
||||
w = str(why)
|
||||
if w.startswith("no state"):
|
||||
return "the sensor is missing"
|
||||
if w.startswith("entity is"):
|
||||
return "the sensor is unavailable"
|
||||
if w.endswith("is not numeric"):
|
||||
return "the reading is not a number"
|
||||
if w.startswith("no unit"):
|
||||
return "the sensor has no unit"
|
||||
if w.startswith("unit "):
|
||||
return "the sensor's unit is not a power unit"
|
||||
return "the reading is not usable"
|
||||
|
||||
|
||||
def row_pv(total_w, count, reversed_=False) -> str:
|
||||
return (f"Solar production: {signed_watts(total_w)}"
|
||||
+ (f" from {count} sources" if count > 1 else "")
|
||||
+ (", sign reversed" if reversed_ else ""))
|
||||
|
||||
|
||||
def row_pv_none(why) -> str:
|
||||
return f"Solar production: no reading, {pv_problem(why)}"
|
||||
|
||||
|
||||
def row_pv_partial(missing, count) -> str:
|
||||
"""`missing` are the 1-based positions of the sources that did not report."""
|
||||
which = names([str(n) for n in missing])
|
||||
return (f"Solar production: no total, source{'s' if len(missing) > 1 else ''} "
|
||||
f"{which} of {count} {'have' if len(missing) > 1 else 'has'} no "
|
||||
f"reading")
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return ("Solar production: clear the single sensor setting, the list "
|
||||
"replaces it")
|
||||
|
||||
|
||||
def row_pv_negative(n, count, value_w, flipped: bool) -> str:
|
||||
fix = ("remove it from" if flipped else "add it to")
|
||||
who = source(n, count)
|
||||
return (f"Solar production: {who} reads {signed_watts(value_w)}, "
|
||||
f"so {fix} the reversed-sign list")
|
||||
|
||||
|
||||
def row_pv_stray(n) -> str:
|
||||
return (f"Solar production: entry {n} of the reversed-sign list is not a "
|
||||
f"solar sensor in use")
|
||||
|
||||
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
|
||||
|
||||
def status_samples() -> list:
|
||||
"""`(function name, colour, text)` for every row and banner, for the test.
|
||||
|
||||
The colour is the one main.py gives the row; a red row may use three
|
||||
sentences. Banners are listed with the colour None.
|
||||
"""
|
||||
clamp = [Clamp(None, 7412, 5000, "rating")]
|
||||
return [
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": row_meter_frozen()})),
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": "x", "banner": banner_peak_reserve()})),
|
||||
("banner_circulating", None, banner_circulating()),
|
||||
("banner_site_direction", None, banner_site_direction()),
|
||||
("banner_direction", None, banner_direction(False)),
|
||||
("banner_direction", None, banner_direction(True)),
|
||||
("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
|
||||
("banner_peak_reserve", None, banner_peak_reserve()),
|
||||
("banner_power_too_high", None, banner_power_too_high()),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
("row_freeze_on", GREEN, row_freeze_on(600, 4465, True)),
|
||||
("row_peak_saturated", RED, row_peak_saturated(5000)),
|
||||
("row_peak_guard_off", AMBER, row_peak_guard_off()),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=True, floor_unknown=False)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=True)),
|
||||
("row_sensor", GREEN, row_sensor(LABEL_SOC, 54, "inverter-b")),
|
||||
("row_sensor", GREEN, row_sensor(LABEL_BATTERY, -1200.0, "inverter-b")),
|
||||
("row_sensor_unset", RED, row_sensor_unset(LABEL_GRID)),
|
||||
("row_sensor_unusable", RED, row_sensor_unusable(LABEL_BATTERY,
|
||||
"inverter-b")),
|
||||
("row_control_unset", RED, row_control_unset("inverter-b")),
|
||||
("row_control_missing", RED, row_control_missing("inverter-b")),
|
||||
("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
|
||||
("row_power_too_high", RED, row_power_too_high(clamp, 3)),
|
||||
("row_power_too_high", RED, row_power_too_high(
|
||||
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
|
||||
("row_power_too_high", RED, row_power_too_high((), 1)),
|
||||
("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
|
||||
("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
|
||||
("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
|
||||
("row_write_refused", RED, row_write_refused("inverter-b")),
|
||||
("row_direction_trip", RED, row_direction_trip()),
|
||||
("row_direction_trip", RED, row_direction_trip("inverter-b")),
|
||||
("row_direction_check", GREEN, row_direction_check(0, 60)),
|
||||
("row_direction_check", GREEN, row_direction_check(12, 60,
|
||||
"inverter-b")),
|
||||
("row_site_direction_trip", RED, row_site_direction_trip()),
|
||||
("row_site_direction_check", GREEN, row_site_direction_check(0, 60)),
|
||||
("row_site_direction_check", GREEN, row_site_direction_check(5, 60)),
|
||||
("row_circulating_trip", RED, row_circulating_trip()),
|
||||
("row_circulating_check", GREEN, row_circulating_check(1200, 20, 60)),
|
||||
("row_one_inverter", GREEN, row_one_inverter("Delivery check")),
|
||||
("row_not_delivering", AMBER, row_not_delivering("inverter-b")),
|
||||
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 30, 120)),
|
||||
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 0, 120)),
|
||||
("row_no_estop", AMBER, row_no_estop()),
|
||||
("row_pv", GREEN, row_pv(3200, 2)),
|
||||
("row_pv", GREEN, row_pv(1800, 1, True)),
|
||||
("row_pv_none", AMBER, row_pv_none("no unit_of_measurement on the "
|
||||
"entity - it cannot be read in watts")),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
|
||||
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
("row_pv_stray", AMBER, row_pv_stray(2)),
|
||||
]
|
||||
|
||||
|
||||
def samples() -> list:
|
||||
"""One rendering of every notice, with realistic values, for the test."""
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user