Trello UX-10 (P2): the last plain-language leftovers from the UX-08 2d test (/home/homelab/UX-08-2d-test-report.md). The choices are recorded in DECISIONS/UX-08-short-notifications.md, section UX-10.
Why the 2d allowlist scan missed the clamp lines
Part 3 of test_messages.py reads a log call's own literal and counts %s as one word. The clamp lines are "Power request too high, which is a software fault: %s …", and the text inside %s was built somewhere else, by loop_clamp_note() / ClampResult.describe(). The scan passed the literal and never saw the text that was logged. The same held for every quoted reason (STALE_READING was 129 words, in capitals).
New part 6 closes the gap:
Every dynamic part of every WARNING+ line in app/ is classified, either as data (a number, a name, a path, a library error) or as a built text. A new part that is in neither list fails.
Every built text is produced by the real code (watchdogs, decide(), loop_clamp_note(), MigrationReport.explain()), or, for a family of builders, by every template of it (PriceRead.no_data, Gate.block). The text and the whole line as logged are then checked.
Mutations show it works. The 2d clamp text, injected, is caught on both clamp lines. A new line quoting an unclassified self.new_wd.reason is caught. "1 times", "(s)", semicolons and doubled full stops are caught.
A quoted reason keeps its code and gets STYLE.md's red-alarm shape (3 sentences of at most 30 words). INFO literals may not carry §n, ISO timestamps or capitals.
Before / after
The "after" texts are real output from the test suite, or from a capture of every WARNING+ line the tests log.
Before
After
Clamp ERROR
…: OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, and it was clamped to +2000 W; the loop's command this cycle was +440 W. This is a BUG SIGNAL … Cut back 1 times so far.
Power request too high, which is a software fault: OUTPUT_CLAMP: the add-on computed +5400 W, over the 5000 W rating, and cut it back to +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far.
Clamp WARNING
…: OUTPUT_CLAMP: the loop's integrator reached its +2000 W anti-windup bound; … Held back 1 times so far.
The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +2000 W limit. The command this cycle was +440 W. Held back 1 time so far.
Price history row
2026-09-26 (96 prices over 24 hours); 2026-09-27 (…)
96 prices over 24 hours for 14 Jan (test_price_history.py). Several days are joined by commas.
Tariff row
…160 published and 8 estimated, 900+3600 s apart, not final yet
…160 published and 8 estimated, one every 15 minutes or every hour
Why (latched stop)
Why: Other: 0 W
Safety stop, power went the wrong way: 0 W
Meter row at start
red Meter: no reading for 0 seconds, limit 30 seconds…
amber Meter: waiting for the first reading, limit 30 seconds (red past the limit, as before)
Why, reading missing
A reading is missing for 0 seconds, control waits (frozen until the next cycle, up to 15 s)
the age counts: …for 7 seconds, control waits
Shadow, every 2 s
INFO SHADOW MODE: not commanding +220 W to inverter-a …
DEBUG Shadow mode: would send +220 W to inverter-a, and holds +0 W. …
Shadow on/off
WARNING
INFO (same text)
Page polls
INFO aiohttp.access … GET /status
only at log level debug
static_profile warning
twice per start
once
HA restart
read sensor.x failed: 502, message='Bad Gateway', url=URL(…), P1 HA websocket: Received message 8:1000 is not str, …
one line: Home Assistant is not answering (error 502), perhaps it is restarting. The add-on keeps trying. and at INFO Home Assistant answers again. Raw text on DEBUG.
INFO
§8.3 price history: …, closed a stale block from 2026-09-27T06:30:00+00:00 … banked, …keys ARE that pack's history…
Price history: …, closed the 15-minute period from 1 Sep at 12:15 at 1322 W, partly measured, and saved it (test_tariff.py), one inverter, so its maintenance history is read where it is and nothing moves
Error texts
NOT_OBSERVED, did NOT land, down AND static_profile
plain sentences
Other reasons rewritten to the same rule: METER_FROZEN, CIRCULATING_POWER, STALE_READING (SAFETY-56's two explanations and "cannot tell which" kept, in order), PEAK_GUARD / PEAK_GUARD_FLOOR_LIMITED / PEAK_GUARD_BLIND (the codes are now single tokens), the migration report, and the price feed, price history and trading-gate reasons.
Display bug causes
Why: Other.main.py turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit") had no entry in messages.WHY_WINNER. The mapping is now main.why_key(). Part 7 finds every Claim.set() in the code and fails if any key it can produce has no words.
Red at 0 s. Until the first reading arrives, P1Ingest.stale is true, and the age is the time since start, so the row was red at 0 s inside its own 30 s limit. Colour change, justified: red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. It is amber, not green, because control waits for the reading. stale itself, which gates control, is unchanged.
Stuck at 0. With the meter reading gone, grid stops changing, so run_control() runs a cycle only every cycle_floor_s, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its escalation are unchanged.
Unchanged
Safety behaviour, alarm log levels (WARNING/ERROR), notification ids, triggers, cadence, and the banner cascade are unchanged. All 36 test files pass locally in the CI image.
Companion: admin/goodwe#61 (the resolved witness push, "after a gap" → "after no answers from"). A pin bump follows that one.
Trello UX-10 (P2): the last plain-language leftovers from the UX-08 2d test (`/home/homelab/UX-08-2d-test-report.md`). The choices are recorded in `DECISIONS/UX-08-short-notifications.md`, section UX-10.
## Why the 2d allowlist scan missed the clamp lines
Part 3 of `test_messages.py` reads a log call's own literal and counts `%s` as one word. The clamp lines are `"Power request too high, which is a software fault: %s …"`, and the text inside `%s` was built somewhere else, by `loop_clamp_note()` / `ClampResult.describe()`. The scan passed the literal and never saw the text that was logged. The same held for every quoted reason (STALE_READING was 129 words, in capitals).
**New part 6** closes the gap:
- Every dynamic part of every WARNING+ line in `app/` is classified, either as data (a number, a name, a path, a library error) or as a built text. A new part that is in neither list fails.
- Every built text is produced by the real code (watchdogs, `decide()`, `loop_clamp_note()`, `MigrationReport.explain()`), or, for a family of builders, by every template of it (`PriceRead.no_data`, `Gate.block`). The text and the whole line as logged are then checked.
- Mutations show it works. The 2d clamp text, injected, is caught on both clamp lines. A new line quoting an unclassified `self.new_wd.reason` is caught. "1 times", "(s)", semicolons and doubled full stops are caught.
A quoted reason keeps its code and gets STYLE.md's red-alarm shape (3 sentences of at most 30 words). INFO literals may not carry `§n`, ISO timestamps or capitals.
## Before / after
The "after" texts are real output from the test suite, or from a capture of every WARNING+ line the tests log.
| | Before | After |
|---|---|---|
| Clamp ERROR | `…: OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, and it was clamped to +2000 W; the loop's command this cycle was +440 W. This is a BUG SIGNAL … Cut back 1 times so far.` | `Power request too high, which is a software fault: OUTPUT_CLAMP: the add-on computed +5400 W, over the 5000 W rating, and cut it back to +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far.` |
| Clamp WARNING | `…: OUTPUT_CLAMP: the loop's integrator reached its +2000 W anti-windup bound; … Held back 1 times so far.` | `The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +2000 W limit. The command this cycle was +440 W. Held back 1 time so far.` |
| Price history row | `2026-09-26 (96 prices over 24 hours); 2026-09-27 (…)` | `96 prices over 24 hours for 14 Jan` (test_price_history.py). Several days are joined by commas. |
| Tariff row | `…160 published and 8 estimated, 900+3600 s apart, not final yet` | `…160 published and 8 estimated, one every 15 minutes or every hour` |
| Why (latched stop) | `Why: Other: 0 W` | `Safety stop, power went the wrong way: 0 W` |
| Meter row at start | red `Meter: no reading for 0 seconds, limit 30 seconds…` | amber `Meter: waiting for the first reading, limit 30 seconds` (red past the limit, as before) |
| Why, reading missing | `A reading is missing for 0 seconds, control waits` (frozen until the next cycle, up to 15 s) | the age counts: `…for 7 seconds, control waits` |
| Shadow, every 2 s | INFO `SHADOW MODE: not commanding +220 W to inverter-a …` | DEBUG `Shadow mode: would send +220 W to inverter-a, and holds +0 W. …` |
| Shadow on/off | WARNING | INFO (same text) |
| Page polls | INFO `aiohttp.access … GET /status` | only at log level debug |
| static_profile warning | twice per start | once |
| HA restart | `read sensor.x failed: 502, message='Bad Gateway', url=URL(…)`, `P1 HA websocket: Received message 8:1000 is not str`, … | one line: `Home Assistant is not answering (error 502), perhaps it is restarting. The add-on keeps trying.` and at INFO `Home Assistant answers again.` Raw text on DEBUG. |
| INFO | `§8.3 price history: …`, `closed a stale block from 2026-09-27T06:30:00+00:00 … banked`, `…keys ARE that pack's history…` | `Price history: …`, `closed the 15-minute period from 1 Sep at 12:15 at 1322 W, partly measured, and saved it` (test_tariff.py), `one inverter, so its maintenance history is read where it is and nothing moves` |
| Error texts | `NOT_OBSERVED`, `did NOT land`, `down AND static_profile` | plain sentences |
Other reasons rewritten to the same rule: METER_FROZEN, CIRCULATING_POWER, STALE_READING (SAFETY-56's two explanations and "cannot tell which" kept, in order), PEAK_GUARD / PEAK_GUARD_FLOOR_LIMITED / PEAK_GUARD_BLIND (the codes are now single tokens), the migration report, and the price feed, price history and trading-gate reasons.
## Display bug causes
- **Why: Other.** `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit") had no entry in `messages.WHY_WINNER`. The mapping is now `main.why_key()`. Part 7 finds every `Claim.set()` in the code and fails if any key it can produce has no words.
- **Red at 0 s.** Until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start, so the row was red at 0 s inside its own 30 s limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. It is amber, not green, because control waits for the reading. `stale` itself, which gates control, is unchanged.
- **Stuck at 0.** With the meter reading gone, `grid` stops changing, so `run_control()` runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its escalation are unchanged.
## Unchanged
Safety behaviour, alarm log levels (WARNING/ERROR), notification ids, triggers, cadence, and the banner cascade are unchanged. All 36 test files pass locally in the CI image.
Companion: admin/goodwe#61 (the resolved witness push, "after a gap" → "after no answers from"). A pin bump follows that one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- 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>
"11 of 48 hours use yesterday's prices", and the maintenance-history line no
longer says "maintenance history" twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part 6 checked the quoted reason alone, so title + reason + tails logged
as 4+ sentences. It now also checks the whole line (at most 3 sentences,
none over 30 words); 25 lines failed and are rewritten (one-sentence
wrappers, two-sentence circulation and peak reasons, STALE_READING's
fixed limits on a DEBUG line). The clamp lines for one inverter are
pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home
Assistant's bare '400: Bad Request' while it loads becomes one plain
line; still WARNING, throttled, a failed write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1. The owner's bar on the new log lines: not met as logged. Fixed in a pushed commit.
Part 6 checked the quoted reason alone for the red-alarm shape. The line as logged was never checked. So a title, a 2-sentence reason and two tails still went out as 4+ sentences:
Power request too high, which is a software fault: OUTPUT_CLAMP: the add-on computed +9000 W, over the 5000 W rating, and cut it back to +5000 W. The command this cycle was +2000 W. Control is still running. Cut back 1 time so far.
That is 4 sentences and 43 words. With the check added, 25 lines failed it: the clamp lines, stale reading, the peak guard, circulation, the price feed fallback (4 sentences), and direction, delivery, frozen meter, allocator, arbiter and gate (a sentence over 30 words).
Fix (pushed):
Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. A mutation shows that the 2d-style line is caught although its reason passes.
Each wrapper is now one sentence.
The circulation and peak guard reasons are now two sentences.
STALE_READING's fixed limits moved to a DEBUG line beside the ERROR. The release clause, the two explanations and "cannot tell which" stay, as test_freshness pins them.
The clamp lines for one inverter are pinned at ≤ 3 sentences and ≤ 30 words, with the code and the numbers kept:
Power request too high, a software fault: OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W. Cut back 1 time so far, control runs on. (30)
Power request too high, a software fault: OUTPUT_CLAMP: asked +3000 W and sent +2500 W, the inverter's own limit. Cut back 1 time so far, control runs on. (28)
The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +5000 W limit, command now +2000 W. Held back 1 time so far. (28)
Several clamped inverters now read as one sentence of asked/sent pairs, so 3 or more inverters no longer make 4+ sentences. The limit that cut back each inverter is still on its own action-log row and in the notification.
describe() drops the rating: the sent value is the limit that bound (it equals the rating for max_w, and the rating did not bind for device).
Not done, follow-up: some lines are still over 30 words in all, up to about 74. These are direction, delivery, frozen meter, stale and peak. They meet the 3-sentence and 30-per-sentence shape. Shortening them further means changing reasons that are persisted in state.json (direction, delivery), and the direction reason is pinned byte for byte in test_watchdog.
2. Safety
Strings blanked, logic diffed (AST, every string set to ''). The logic changes are only these:
why_key(), a pure extraction;
the why_words property;
the meter-row branch;
Outage and its calls, and _receive_json;
the _WarnOnce filter;
access_log();
write_clamp_note();
shadow INFO→DEBUG and shadow on/off WARNING→INFO;
count()/when()/day_words()/spacing_words().
No trigger, latch, claim, threshold, notification id or cadence changed. No alarm log level changed. The two shadow lines are not alarms.
Shadow line to DEBUG: nothing an operator needs is lost at INFO. The "Shadow mode is on/off" INFO line, the banner, the Shadow mode row, and the action log's "SHADOW: would have commanded N W" still show it. DOCS §Shadow mode points to the status page and action log, not the add-on log. Only the per-inverter split of the would-be command is DEBUG-only now. The site total is on the row and in the action log.
Access log: only web.start() uses it. Nothing in admin/goodwe or /home/homelab/*.sh greps aiohttp.access or GET /status.
Meter row:
P1Ingest.stale is untouched, and it gates control.
Amber shows only while age_s() is None, which means no reading ever arrived, and published_age_s (the time since start) is ≤ the limit.
A site that never gets a reading goes red after the limit (part 7 drives 31 s).
A reading that arrived and then stopped is red, as before.
Age on page request:_why_waiting_since is initialised by the setter in __init__ ("Starting"). Every later why_words = clears it. Nothing runs in threads (no executor or to_thread in app/), so the property's two reads cannot race. It is also clamped at ≥ 0.
Outage note (no change asked):
A 502/503/504 or connection error now returns the same None or False, but it skips _moan, which is correct.
One side effect: a websocket fault that is not a restart (a ClientError that persists) is said only once, as "perhaps it is restarting". The raw text is on DEBUG. The meter row goes red after 30 s in any case.
3. Scan (part 6/7): verified by injection
A semicolon plus "THIS IS BAD" injected into the circulation reason is caught: FAIL every built text … (2 bad).
A Claim.set("safety", …, "battery too hot") is caught: FAIL … missing: ['battery too hot'].
A new source Claim.set("thermal", …) is caught: missing: ['thermal'].
A non-constant reason becomes the key '', which also fails. That is safe.
DATA classification: every entry is a number, a name, a path, an option value or a library/exception text. None is a free-text reason. The key is file:expression, so a future builder that assigns free text to a variable named err in main.py would pass. That is a naming limit, not an abuse today. hass.py:what is add-on text: 3 short fixed phrases, all fine.
4. PEAK GUARD → PEAK_GUARD
admin/goodwe: 0 hits. /home/homelab/*.sh: 0 hits for either form.
In goodwe-addon, the old form is left only in prose: comments, DOCS/CHANGELOG/DECISIONS history, and the test_peakguard.py fixture strings reason="PEAK GUARD FLOOR-LIMITED: x" (inputs, never matched) and its print at :2355.
No code or test matches the reason text on it.
5. Leftover: number.set_value rejected (400): 400: Bad Request. Fixed.
New text: "Home Assistant refused number.set_value for number.sp (error 400, no details). The entity may still be loading, or the value is out of range."
A body with more than the bare status line is still quoted (for example, HA's out-of-range message).
Still WARNING, still once a minute, still a failed write (False). Part 7 pins both cases.
Pushed as e0b5787 (ls-remote verified). Local full suite in the CI image with the infra repo at the pin: 36/36 PASS. Waiting for CI.
## Review (UX-10), round 1
### 1. The owner's bar on the new log lines: not met as logged. Fixed in a pushed commit.
Part 6 checked the **quoted reason** alone for the red-alarm shape. The **line as logged** was never checked. So a title, a 2-sentence reason and two tails still went out as 4+ sentences:
> Power request too high, which is a software fault: OUTPUT_CLAMP: the add-on computed +9000 W, over the 5000 W rating, and cut it back to +5000 W. The command this cycle was +2000 W. Control is still running. Cut back 1 time so far.
That is 4 sentences and 43 words. With the check added, 25 lines failed it: the clamp lines, stale reading, the peak guard, circulation, the price feed fallback (4 sentences), and direction, delivery, frozen meter, allocator, arbiter and gate (a sentence over 30 words).
**Fix (pushed):**
- Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. A mutation shows that the 2d-style line is caught although its reason passes.
- Each wrapper is now one sentence.
- The circulation and peak guard reasons are now two sentences.
- `STALE_READING`'s fixed limits moved to a DEBUG line beside the ERROR. The release clause, the two explanations and "cannot tell which" stay, as test_freshness pins them.
- The clamp lines for one inverter are pinned at ≤ 3 sentences and ≤ 30 words, with the code and the numbers kept:
- `Power request too high, a software fault: OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W. Cut back 1 time so far, control runs on.` (30)
- `Power request too high, a software fault: OUTPUT_CLAMP: asked +3000 W and sent +2500 W, the inverter's own limit. Cut back 1 time so far, control runs on.` (28)
- `The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +5000 W limit, command now +2000 W. Held back 1 time so far.` (28)
- Several clamped inverters now read as one sentence of asked/sent pairs, so 3 or more inverters no longer make 4+ sentences. The limit that cut back each inverter is still on its own action-log row and in the notification.
- `describe()` drops the rating: the sent value *is* the limit that bound (it equals the rating for `max_w`, and the rating did not bind for `device`).
**Not done, follow-up:** some lines are still over 30 words in all, up to about 74. These are direction, delivery, frozen meter, stale and peak. They meet the 3-sentence and 30-per-sentence shape. Shortening them further means changing reasons that are persisted in state.json (direction, delivery), and the direction reason is pinned byte for byte in test_watchdog.
### 2. Safety
- **Strings blanked, logic diffed** (AST, every string set to ''). The logic changes are only these:
- `why_key()`, a pure extraction;
- the `why_words` property;
- the meter-row branch;
- `Outage` and its calls, and `_receive_json`;
- the `_WarnOnce` filter;
- `access_log()`;
- `write_clamp_note()`;
- shadow INFO→DEBUG and shadow on/off WARNING→INFO;
- `count()`/`when()`/`day_words()`/`spacing_words()`.
No trigger, latch, claim, threshold, notification id or cadence changed. No alarm log level changed. The two shadow lines are not alarms.
- **Shadow line to DEBUG:** nothing an operator needs is lost at INFO. The "Shadow mode is on/off" INFO line, the banner, the `Shadow mode` row, and the action log's "SHADOW: would have commanded N W" still show it. DOCS §Shadow mode points to the status page and action log, not the add-on log. Only the per-inverter split of the would-be command is DEBUG-only now. The site total is on the row and in the action log.
- **Access log:** only `web.start()` uses it. Nothing in admin/goodwe or `/home/homelab/*.sh` greps `aiohttp.access` or `GET /status`.
- **Meter row:**
- `P1Ingest.stale` is untouched, and it gates control.
- Amber shows only while `age_s() is None`, which means no reading ever arrived, and `published_age_s` (the time since start) is ≤ the limit.
- A site that never gets a reading goes red after the limit (part 7 drives 31 s).
- A reading that arrived and then stopped is red, as before.
- **Age on page request:** `_why_waiting_since` is initialised by the setter in `__init__` ("Starting"). Every later `why_words =` clears it. Nothing runs in threads (no executor or `to_thread` in app/), so the property's two reads cannot race. It is also clamped at ≥ 0.
- **`Outage` note (no change asked):**
- A 502/503/504 or connection error now returns the same None or False, but it skips `_moan`, which is correct.
- One side effect: a websocket fault that is *not* a restart (a `ClientError` that persists) is said only once, as "perhaps it is restarting". The raw text is on DEBUG. The meter row goes red after 30 s in any case.
### 3. Scan (part 6/7): verified by injection
- A semicolon plus "THIS IS BAD" injected into the circulation reason is caught: `FAIL every built text … (2 bad)`.
- A `Claim.set("safety", …, "battery too hot")` is caught: `FAIL … missing: ['battery too hot']`.
- A new source `Claim.set("thermal", …)` is caught: `missing: ['thermal']`.
- A non-constant reason becomes the key `''`, which also fails. That is safe.
- **DATA classification:** every entry is a number, a name, a path, an option value or a library/exception text. None is a free-text reason. The key is `file:expression`, so a future builder that assigns free text to a variable named `err` in main.py would pass. That is a naming limit, not an abuse today. `hass.py:what` is add-on text: 3 short fixed phrases, all fine.
### 4. `PEAK GUARD` → `PEAK_GUARD`
- admin/goodwe: 0 hits. `/home/homelab/*.sh`: 0 hits for either form.
- In goodwe-addon, the old form is left only in prose: comments, DOCS/CHANGELOG/DECISIONS history, and the `test_peakguard.py` fixture strings `reason="PEAK GUARD FLOOR-LIMITED: x"` (inputs, never matched) and its print at :2355.
- No code or test matches the reason text on it.
### 5. Leftover: `number.set_value rejected (400): 400: Bad Request`. Fixed.
- New text: "Home Assistant refused number.set_value for number.sp (error 400, no details). The entity may still be loading, or the value is out of range."
- A body with more than the bare status line is still quoted (for example, HA's out-of-range message).
- Still WARNING, still once a minute, still a failed write (`False`). Part 7 pins both cases.
Pushed as **e0b5787** (ls-remote verified). Local full suite in the CI image with the infra repo at the pin: 36/36 PASS. Waiting for CI.
The line as logged is now checked (at most 3 sentences, none over 30 words).
The clamp lines are pinned at 30 words or fewer.
A longer total on the direction, delivery, frozen, stale and peak lines is a follow-up, recorded in DECISIONS/UX-08 item 7.
2. Safety: no defect found.
The blank-string diff of e0b5787 changes only write_clamp_note's single-unit branch, the 400 body filter and a new DEBUG line.
No level, id, trigger or cadence changed.
3. The scan: verified by injection, with no hiding path in the DATA list.
4. PEAK GUARD: no hits in code, admin/goodwe or /home/homelab/*.sh. The only hits are prose and test fixture inputs.
5. The HA 400 leftover: fixed and pinned in part 7.
CI on e0b5787 is green (checks / unit and infra-pin / fresh). release/1.0 has not moved (125b070). Merging with head_commit_id pinned.
## Review, round 2: every point resolved
- **1. The owner's bar, fixed in e0b5787.**
- The line as logged is now checked (at most 3 sentences, none over 30 words).
- The clamp lines are pinned at 30 words or fewer.
- A longer total on the direction, delivery, frozen, stale and peak lines is a follow-up, recorded in DECISIONS/UX-08 item 7.
- **2. Safety: no defect found.**
- The blank-string diff of e0b5787 changes only `write_clamp_note`'s single-unit branch, the 400 body filter and a new DEBUG line.
- No level, id, trigger or cadence changed.
- **3. The scan: verified by injection**, with no hiding path in the DATA list.
- **4. `PEAK GUARD`:** no hits in code, admin/goodwe or `/home/homelab/*.sh`. The only hits are prose and test fixture inputs.
- **5. The HA 400 leftover:** fixed and pinned in part 7.
CI on e0b5787 is green (`checks / unit` and `infra-pin / fresh`). release/1.0 has not moved (125b070). Merging with `head_commit_id` pinned.
admin
merged commit 2b14b88cb4 into release/1.02026-09-27 12:18:23 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Trello UX-10 (P2): the last plain-language leftovers from the UX-08 2d test (
/home/homelab/UX-08-2d-test-report.md). The choices are recorded inDECISIONS/UX-08-short-notifications.md, section UX-10.Why the 2d allowlist scan missed the clamp lines
Part 3 of
test_messages.pyreads a log call's own literal and counts%sas one word. The clamp lines are"Power request too high, which is a software fault: %s …", and the text inside%swas built somewhere else, byloop_clamp_note()/ClampResult.describe(). The scan passed the literal and never saw the text that was logged. The same held for every quoted reason (STALE_READING was 129 words, in capitals).New part 6 closes the gap:
app/is classified, either as data (a number, a name, a path, a library error) or as a built text. A new part that is in neither list fails.decide(),loop_clamp_note(),MigrationReport.explain()), or, for a family of builders, by every template of it (PriceRead.no_data,Gate.block). The text and the whole line as logged are then checked.self.new_wd.reasonis caught. "1 times", "(s)", semicolons and doubled full stops are caught.A quoted reason keeps its code and gets STYLE.md's red-alarm shape (3 sentences of at most 30 words). INFO literals may not carry
§n, ISO timestamps or capitals.Before / after
The "after" texts are real output from the test suite, or from a capture of every WARNING+ line the tests log.
…: OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, and it was clamped to +2000 W; the loop's command this cycle was +440 W. This is a BUG SIGNAL … Cut back 1 times so far.Power request too high, which is a software fault: OUTPUT_CLAMP: the add-on computed +5400 W, over the 5000 W rating, and cut it back to +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far.…: OUTPUT_CLAMP: the loop's integrator reached its +2000 W anti-windup bound; … Held back 1 times so far.The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +2000 W limit. The command this cycle was +440 W. Held back 1 time so far.2026-09-26 (96 prices over 24 hours); 2026-09-27 (…)96 prices over 24 hours for 14 Jan(test_price_history.py). Several days are joined by commas.…160 published and 8 estimated, 900+3600 s apart, not final yet…160 published and 8 estimated, one every 15 minutes or every hourWhy: Other: 0 WSafety stop, power went the wrong way: 0 WMeter: no reading for 0 seconds, limit 30 seconds…Meter: waiting for the first reading, limit 30 seconds(red past the limit, as before)A reading is missing for 0 seconds, control waits(frozen until the next cycle, up to 15 s)…for 7 seconds, control waitsSHADOW MODE: not commanding +220 W to inverter-a …Shadow mode: would send +220 W to inverter-a, and holds +0 W. …aiohttp.access … GET /statusread sensor.x failed: 502, message='Bad Gateway', url=URL(…),P1 HA websocket: Received message 8:1000 is not str, …Home Assistant is not answering (error 502), perhaps it is restarting. The add-on keeps trying.and at INFOHome Assistant answers again.Raw text on DEBUG.§8.3 price history: …,closed a stale block from 2026-09-27T06:30:00+00:00 … banked,…keys ARE that pack's history…Price history: …,closed the 15-minute period from 1 Sep at 12:15 at 1322 W, partly measured, and saved it(test_tariff.py),one inverter, so its maintenance history is read where it is and nothing movesNOT_OBSERVED,did NOT land,down AND static_profileOther reasons rewritten to the same rule: METER_FROZEN, CIRCULATING_POWER, STALE_READING (SAFETY-56's two explanations and "cannot tell which" kept, in order), PEAK_GUARD / PEAK_GUARD_FLOOR_LIMITED / PEAK_GUARD_BLIND (the codes are now single tokens), the migration report, and the price feed, price history and trading-gate reasons.
Display bug causes
main.pyturns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit") had no entry inmessages.WHY_WINNER. The mapping is nowmain.why_key(). Part 7 finds everyClaim.set()in the code and fails if any key it can produce has no words.P1Ingest.staleis true, and the age is the time since start, so the row was red at 0 s inside its own 30 s limit. Colour change, justified: red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. It is amber, not green, because control waits for the reading.staleitself, which gates control, is unchanged.gridstops changing, sorun_control()runs a cycle only everycycle_floor_s, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its escalation are unchanged.Unchanged
Safety behaviour, alarm log levels (WARNING/ERROR), notification ids, triggers, cadence, and the banner cascade are unchanged. All 36 test files pass locally in the CI image.
Companion: admin/goodwe#61 (the resolved witness push, "after a gap" → "after no answers from"). A pin bump follows that one.
🤖 Generated with Claude Code
Review (UX-10), round 1
1. The owner's bar on the new log lines: not met as logged. Fixed in a pushed commit.
Part 6 checked the quoted reason alone for the red-alarm shape. The line as logged was never checked. So a title, a 2-sentence reason and two tails still went out as 4+ sentences:
That is 4 sentences and 43 words. With the check added, 25 lines failed it: the clamp lines, stale reading, the peak guard, circulation, the price feed fallback (4 sentences), and direction, delivery, frozen meter, allocator, arbiter and gate (a sentence over 30 words).
Fix (pushed):
STALE_READING's fixed limits moved to a DEBUG line beside the ERROR. The release clause, the two explanations and "cannot tell which" stay, as test_freshness pins them.Power request too high, a software fault: OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W. Cut back 1 time so far, control runs on.(30)Power request too high, a software fault: OUTPUT_CLAMP: asked +3000 W and sent +2500 W, the inverter's own limit. Cut back 1 time so far, control runs on.(28)The battery gives less than asked: OUTPUT_CLAMP: the correction the add-on builds up reached its +5000 W limit, command now +2000 W. Held back 1 time so far.(28)describe()drops the rating: the sent value is the limit that bound (it equals the rating formax_w, and the rating did not bind fordevice).Not done, follow-up: some lines are still over 30 words in all, up to about 74. These are direction, delivery, frozen meter, stale and peak. They meet the 3-sentence and 30-per-sentence shape. Shortening them further means changing reasons that are persisted in state.json (direction, delivery), and the direction reason is pinned byte for byte in test_watchdog.
2. Safety
Strings blanked, logic diffed (AST, every string set to ''). The logic changes are only these:
why_key(), a pure extraction;why_wordsproperty;Outageand its calls, and_receive_json;_WarnOncefilter;access_log();write_clamp_note();count()/when()/day_words()/spacing_words().No trigger, latch, claim, threshold, notification id or cadence changed. No alarm log level changed. The two shadow lines are not alarms.
Shadow line to DEBUG: nothing an operator needs is lost at INFO. The "Shadow mode is on/off" INFO line, the banner, the
Shadow moderow, and the action log's "SHADOW: would have commanded N W" still show it. DOCS §Shadow mode points to the status page and action log, not the add-on log. Only the per-inverter split of the would-be command is DEBUG-only now. The site total is on the row and in the action log.Access log: only
web.start()uses it. Nothing in admin/goodwe or/home/homelab/*.shgrepsaiohttp.accessorGET /status.Meter row:
P1Ingest.staleis untouched, and it gates control.age_s() is None, which means no reading ever arrived, andpublished_age_s(the time since start) is ≤ the limit.Age on page request:
_why_waiting_sinceis initialised by the setter in__init__("Starting"). Every laterwhy_words =clears it. Nothing runs in threads (no executor orto_threadin app/), so the property's two reads cannot race. It is also clamped at ≥ 0.Outagenote (no change asked):_moan, which is correct.ClientErrorthat persists) is said only once, as "perhaps it is restarting". The raw text is on DEBUG. The meter row goes red after 30 s in any case.3. Scan (part 6/7): verified by injection
FAIL every built text … (2 bad).Claim.set("safety", …, "battery too hot")is caught:FAIL … missing: ['battery too hot'].Claim.set("thermal", …)is caught:missing: ['thermal'].'', which also fails. That is safe.file:expression, so a future builder that assigns free text to a variable namederrin main.py would pass. That is a naming limit, not an abuse today.hass.py:whatis add-on text: 3 short fixed phrases, all fine.4.
PEAK GUARD→PEAK_GUARD/home/homelab/*.sh: 0 hits for either form.test_peakguard.pyfixture stringsreason="PEAK GUARD FLOOR-LIMITED: x"(inputs, never matched) and its print at :2355.5. Leftover:
number.set_value rejected (400): 400: Bad Request. Fixed.False). Part 7 pins both cases.Pushed as
e0b5787(ls-remote verified). Local full suite in the CI image with the infra repo at the pin: 36/36 PASS. Waiting for CI.Review, round 2: every point resolved
e0b5787.e0b5787changes onlywrite_clamp_note's single-unit branch, the 400 body filter and a new DEBUG line.PEAK GUARD: no hits in code, admin/goodwe or/home/homelab/*.sh. The only hits are prose and test fixture inputs.CI on
e0b5787is green (checks / unitandinfra-pin / fresh). release/1.0 has not moved (125b070). Merging withhead_commit_idpinned.