UX-08 2d review: action log reachable in status JSON, engaged peak row pinned, imported keys expanded
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s

- status_payload() also returns the action log as `events`. The page
  does not render it. Some entries (a dropped inverter's reason, the peak
  guard's projection, unplaced watts) are written nowhere else, and DOCS
  still points installers at the action log.
- test_peakguard pins the engaged row's ordered pair (the battery's share,
  then the limit).
- test_messages expands setting keys named by constants imported from
  another app module, so the shown-text 30-word scan counts them.
- p1.py: 'A meter message could not be read'. DOCS glossary: control
  board in plain words, with the hardware in an installer note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude review
2026-09-27 08:29:14 +02:00
parent 6a95447248
commit 569f16aa91
8 changed files with 57 additions and 13 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ This applies to everything a home owner or installer reads: notifications, phone
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its own codes, because that is the evidence an installer searches for.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown.
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
## 2. Numbers
@@ -52,7 +52,7 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line
1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words.
2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain.
3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W".
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it.
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision.
5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*.
6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start.
7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again.
+8 -5
View File
@@ -9,10 +9,10 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Words used on this page
- **Control board**: the small computer wired to the inverter's RS485 meter
bus (a T-CAN485 board with an ESP32 chip). It passes the add-on's power
request to the inverter. If the add-on goes quiet for about 30 seconds, it
sets the inverter to 0 W by itself.
- **Control board**: the small computer on the inverter's meter cable. It
passes the add-on's power request to the inverter. If the add-on goes quiet
for about 30 seconds, it sets the inverter to 0 W by itself. (For
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
it off stops the inverter, whatever the add-on or the control board asks.
- **Developer Tools**: the Home Assistant page that lists every entity and its
@@ -3183,7 +3183,10 @@ buttons reach the inverter.
*For installers: the Configuration page is UX-02. The line under the buttons is
`messages.hint()`, the plain form of the newest entry of the add-on's own
action log (`Controller.events`), which the page no longer shows.*
action log (`Controller.events`). The page no longer shows that log, but the
`status` JSON still carries its last 40 entries as `events`: open the add-on
page and add `status` to its address. The action-log tables in this document
refer to it.*
## Status page
+5
View File
@@ -5260,6 +5260,11 @@ class Controller:
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first. The page does not show it
# (UX-08 2d), but an installer can still read it in the `status`
# JSON, because several events (a dropped inverter's reason, the
# peak guard's projection) are written nowhere else.
"events": list(self.events),
}
async def handle_action(self, what: str):
+1 -1
View File
@@ -472,7 +472,7 @@ class P1Ingest:
"""
self.parse_errors += 1
self.last_error = str(err)
_LOG.warning("Meter reading refused: %s", err)
_LOG.warning("A meter message could not be read: %s", err)
# -- what consumers read -------------------------------------------------
def age_s(self) -> float | None:
+21 -5
View File
@@ -402,6 +402,18 @@ def strings_in(node, expand=False):
yield from strings_in(child, expand)
def _module_constants(tree):
"""Module-level `NAME = "text"` assignments."""
return {t.id: n.value.value for n in tree.body
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
for t in n.targets if isinstance(t, ast.Name)}
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
@@ -409,12 +421,16 @@ def collect():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
# The file's own constants, and those it imports from another app
# module (`from .validate import TAXES_PROFILE_OPTION`), so a key
# named by a constant from elsewhere is expanded too.
CONSTANTS.clear()
CONSTANTS.update({t.id: n.value.value for n in tree.body
if isinstance(n, ast.Assign)
and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
for t in n.targets if isinstance(t, ast.Name)})
CONSTANTS.update(_module_constants(tree))
for n in tree.body:
if isinstance(n, ast.ImportFrom) and n.level == 1 and n.module:
theirs = _MODCONST.get(n.module, {})
CONSTANTS.update({a.asname or a.name: theirs[a.name]
for a in n.names if a.name in theirs})
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
for k, v in zip(node.keys, node.values):
+13
View File
@@ -1221,6 +1221,19 @@ rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
check("an engagement that is not saturated raises NO notification - §10.3's "
"'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house "
"load bigger than the inverter is not a bug",
+7
View File
@@ -375,6 +375,13 @@ check("the status page says so in words: a red row, at 0 W, cleared by start",
for c in ctrl.checks()))
check("the banner leads with it",
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)