UX-11 review: restored stops log their figures to events, the quiet-meter WARNING is rate-limited
checks / unit (pull_request) Successful in 9m7s
infra-pin / fresh (pull_request) Successful in 1s

- After a restart, each restored stop (direction per inverter and site,
  delivery, circulation) also writes its saved reason to the action log.
  log_level defaults to info and events starts empty, so the DEBUG
  evidence alone left the figures only in state.json.
- A flapping meter (ha_signed under a flat load, 42-97 s between changes)
  logs at most one WARNING/INFO pair per 10 minutes, with the episodes in
  between at DEBUG.
- LONG_LINE_OK refuses any key whose text is a safety reason.
- test_watchdog keeps the byte pin beside the meaning pin, since the text
  did not change.
- DOCS says the DEBUG line needs log_level debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:21:05 +02:00
co-authored by Claude Opus 5.5
parent c628099e69
commit 3c298af3d8
5 changed files with 82 additions and 13 deletions
+44 -4
View File
@@ -119,6 +119,9 @@ WRITE_BUDGET_S = 4.0
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
REANNOUNCE_S = 300.0
# UX-11 review: a quiet meter logs at most one WARNING this often, because on
# `ha_signed` a healthy meter under a flat load can go 42-97 s between changes.
METER_QUIET_REPEAT_S = 600.0
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
# --------------------------------------------------------------------------- #
@@ -619,6 +622,13 @@ class Controller:
messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# UX-11 review: `events` starts empty and DEBUG is off by
# default, so the saved figures go on the action log too.
saved = inv.sign_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"FAILSAFE {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("stop_restored",
unit=inv.name))
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
@@ -645,6 +655,11 @@ class Controller:
inv.name, messages.reason_code(inv.delivery_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.delivery_wd.reason or "(reason not recorded)")
saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"NOT DELIVERING {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("not_delivering",
unit=inv.name))
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
@@ -678,6 +693,10 @@ class Controller:
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.site_sign_wd.reason or "(reason not recorded)")
self.log_event("FAILSAFE site, saved before the restart: "
+ (self.site_sign_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("site_direction"))
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
@@ -733,6 +752,10 @@ class Controller:
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.circ_wd.reason or "(reason not recorded)")
self.log_event("CIRCULATING POWER, saved before the restart: "
+ (self.circ_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("circulating"))
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
# itself is the cheap half.
@@ -940,6 +963,9 @@ class Controller:
self.why_words = "Starting"
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
self._meter_quiet_logged = False
# ...when the last WARNING was, and whether this episode logged one.
self._meter_quiet_warned = None
self._meter_quiet_loud = False
# ALLOC-01. The allocation record from the last resolution, or None
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
@@ -1912,16 +1938,30 @@ class Controller:
# UX-11: the red meter row says "the add-on log shows why", so a meter
# that goes quiet gets one WARNING per episode, not one per cycle. It
# is a log line only: no notification, and nothing it decides.
# A flapping meter logs at most one WARNING per METER_QUIET_REPEAT_S,
# and the episodes in between go to DEBUG.
if self.p1_enabled:
if (self.p1.stale and not self._meter_quiet_logged
and self.p1.published_age_s > self.p1.max_age_s):
self._meter_quiet_logged = True
_LOG.warning("No meter reading for over %s, so control "
"waits and then sets the battery to 0 W.",
messages.seconds(self.p1.max_age_s))
now = _mono()
self._meter_quiet_loud = (
self._meter_quiet_warned is None
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
if self._meter_quiet_loud:
self._meter_quiet_warned = now
_LOG.warning("No meter reading for over %s, so control "
"waits and then sets the battery to 0 W.",
messages.seconds(self.p1.max_age_s))
else:
_LOG.debug("No meter reading for over %s again.",
messages.seconds(self.p1.max_age_s))
elif not self.p1.stale and self._meter_quiet_logged:
self._meter_quiet_logged = False
_LOG.info("Meter readings are back.")
if self._meter_quiet_loud:
_LOG.info("Meter readings are back.")
else:
_LOG.debug("Meter readings are back.")
if self.inputs_ok:
if self.inputs_bad_since is not None: