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
+23 -4
View File
@@ -1227,7 +1227,9 @@ check(f"UX-11: no WARNING+ line runs over {LINE_WORDS} words as logged, except "
f"the {len(LONG_LINE_OK)} listed families, and none of them is a safety "
f"reason (cap {LONG_LINE_CAP})",
len(LONG_LINE_OK) <= LONG_LINE_CAP
and not any("reason_code" in k for k in LONG_LINE_OK))
and not any("reason_code" in k
or TEXT.get(k) in SAFETY_REASONS.values()
for k in LONG_LINE_OK))
check(f"...and every listed family still runs long, so the list only shrinks "
f"(no longer long: {sorted(set(LONG_LINE_OK) - _long_seen)})",
set(LONG_LINE_OK) <= _long_seen)
@@ -1397,6 +1399,7 @@ import logging # noqa: E402
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
from app.arbiter import Claim # noqa: E402
from app.main import Controller, why_key # noqa: E402
from app import main as _main # noqa: E402
class _Rec(logging.Handler):
@@ -1695,7 +1698,12 @@ def _meter_at(age_s):
def _quiet_episodes():
for age in (1, 40, 45, 50, 1, 2, 60, 65):
# Two quiet episodes close together, then a third 10 minutes on.
for age in (1, 40, 45, 50, 1, 2, 60, 65, 1):
_meter_at(age)
asyncio.run(_c.read_inputs())
_c._meter_quiet_warned -= _main.METER_QUIET_REPEAT_S
for age in (40, 45, 1):
_meter_at(age)
asyncio.run(_c.read_inputs())
@@ -1704,9 +1712,13 @@ _mq = [r for r in _captured("goodwe", _quiet_episodes)
if "meter" in r.getMessage().lower()]
_mq_warn = [r.getMessage() for r in _mq if r.levelno == logging.WARNING]
_mq_info = [r.getMessage() for r in _mq if r.levelno == logging.INFO]
_mq_dbg = [r.getMessage() for r in _mq if r.levelno == logging.DEBUG]
check(f"a meter that goes quiet logs one WARNING per episode, not per cycle, "
f"and one INFO when readings return ({_mq_warn}, {_mq_info})",
len(_mq_warn) == 2 and _mq_info == ["Meter readings are back."]
f"and one INFO when readings return, and a flapping meter at most one "
f"pair per {_main.METER_QUIET_REPEAT_S:.0f} s ({_mq_warn}, {_mq_info}, "
f"{_mq_dbg})",
len(_mq_warn) == 2 and _mq_info == ["Meter readings are back."] * 2
and len(_mq_dbg) == 2
and _mq_warn[0] == "No meter reading for over 30 seconds, so control "
"waits and then sets the battery to 0 W."
and len(words(_mq_warn[0])) <= LINE_WORDS)
@@ -1763,6 +1775,13 @@ check("...each ERROR line at start is one plain sentence with the code, at "
and any(m.endswith(f": {messages.NO_REASON_SAVED}.") for m in _rs_err))
check("...and the saved text, figures and all, is on a DEBUG line beside it",
all(any(t in m for m in _rs_dbg) for t in _OLD.values()))
# UX-11 review: `log_level` defaults to info and `events` starts empty, so the
# saved figures also go on the action log, where `status` shows them.
_rs_ev = _restart[0].events if _restart else []
check(f"...and in the action log, which an installer reads at the default "
f"log level ({len(_rs_ev)} events)",
all(any(t in e for e in _rs_ev) for t in _OLD.values())
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
_main_src = (APP / "main.py").read_text(encoding="utf-8")