UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s

- test_messages.py part 6: every dynamic part of a WARNING+ line is classified
  as data or as a built text, and every built text is produced by the real
  code and checked. This is the gap the 2d scan had: it counted `%s` as one
  word, so reasons built by loop_clamp_note() and friends were never seen.
- Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept):
  OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak
  guard (codes now PEAK_GUARD*), the migration report, price feed, price
  history and gate reasons. Plurals via messages.times()/count().
- Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff
  "one every 15 minutes or every hour", "not final yet" dropped.
- Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO,
  aiohttp access log only at log level debug, settings warnings once per
  start, one plain line per Home Assistant outage (hass.Outage).
- INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE",
  "NOT_OBSERVED", "did NOT land", "AND".
- Display bugs: WHY_WINNER covers every winner and safety reason
  (main.why_key(), part 7 finds them in the code); the meter row is amber
  "waiting for the first reading" inside its limit after a start; the
  missing-reading age is counted when the page asks.
- STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section).

No change to safety behaviour, alarm levels, notification ids, triggers,
cadence or the banner cascade. One row colour changes (meter at start),
justified in DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-27 10:58:43 +02:00
co-authored by Claude Opus 5.5
parent 125b0702a2
commit 23a3a3f5b5
33 changed files with 1268 additions and 347 deletions
+22 -13
View File
@@ -114,6 +114,8 @@ import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo
from . import messages
_LOG = logging.getLogger("goodwe.tariff")
QUARTER_S = 900
@@ -339,8 +341,8 @@ class PeakStore:
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong. A store that OPENED and then broke is
# the error case, and it is logged as one below.
_LOG.info("no peak store: %s - the monthly peak will read "
"NOT_OBSERVED", self.last_error)
_LOG.info("No peak store (%s), so the month peak cannot be read "
"and only the peak limit is used.", self.last_error)
return
try:
self._db = sqlite3.connect(self.path, timeout=5.0,
@@ -360,8 +362,9 @@ class PeakStore:
# fault must not stop a controller that is regulating a battery. The
# cost of continuing is a peak that reads NOT_OBSERVED, and every
# reader is built to treat that as "do not use me", not as 0 W.
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
_LOG.error("The peak store at %s cannot be used (%s), so only the "
"peak limit is used. The month peak is unknown, not "
"zero.", self.path, err)
def close(self) -> None:
if self._db is not None:
@@ -672,12 +675,12 @@ class PeakTracker:
now = now or datetime.now(timezone.utc)
snap = self.store.load_partial()
if not snap:
self.restore_state = ("fresh: no checkpoint" if self.store.ok
else "fresh: store unreadable")
self.restore_state = ("fresh start, nothing saved" if self.store.ok
else "fresh start, the store cannot be read")
return self.restore_state
block = snap.get("block")
if block is None:
self.restore_state = "fresh: checkpoint had no open block"
self.restore_state = "fresh start, no open 15-minute period saved"
return self.restore_state
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
@@ -686,11 +689,13 @@ class PeakTracker:
# A reshaped accumulator (meter_phases changed) is a different
# measurement, not a resumable one. Say so; do not half-resume.
self.store.clear_partial()
self.restore_state = "fresh: checkpoint does not fit this averager"
self.restore_state = ("fresh start, the saved period does not "
"fit the number of phases")
return self.restore_state
self.restore_state = (
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
f"resumed the open 15-minute period at "
f"{snap.get('elapsed', 0.0):.0f} s of {QUARTER_S} s, with "
f"{snap.get('acc', 0.0) / QUARTER_S:.0f} W counted so far")
return self.restore_state
# The block ended while the add-on was down. Its measured part really
@@ -704,10 +709,14 @@ class PeakTracker:
if snap.get("pp_acc") and snap.get("last_pp") else None))
banked = self.store.record_block(closed, self.policy)
self.store.clear_partial()
# UX-10: plain words and the local time, not ISO and "banked".
self.restore_state = (
f"closed a stale block from {closed.start.isoformat()} at "
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
+ ("banked" if banked else "NOT banked - store unwritable"))
f"closed the 15-minute period from "
f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
f"{closed.offtake_avg_w:.0f} W, partly measured, "
+ ("and saved it" if banked
else "but could not save it, because the store cannot be "
"written"))
return self.restore_state
# -- steady state -------------------------------------------------------