- 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>
20 KiB
UX-08 phase 2a: short notifications, and what moved out of them
The owner decided on 2026-09-27: a title of at most about 5 everyday words and a one-sentence body. A red safety alarm may use up to 3 sentences, so no action-relevant information is lost. Titles are neutral, with no "GoodWe:" prefix. HA entity ids appear only in refusals. The rule is STYLE.md at the repository root, the texts are in app/messages.py, and test_messages.py enforces both. The detail each notification used to carry is in DOCS.md under "Notifications", and the body links to it.
Unchanged: every notification_id, every trigger and cadence (edges, SAFETY-34 re-announce, per-unit ids), every log line and every status row. The status banner and rows are phase 2b, refusals and option texts 2c, logs 2d; ux08_allowlist.txt lists them until then.
Where a choice was made
- Severity of "not delivering" (SAFETY-42). The notification is an alarm, so it may use 3 sentences. Its log line is ERROR and the alarm is latched. The page banner stays amber, as SAFETY-42 decided, because control keeps running; the two answer different questions (is control running / must a person act) and HA shows no colour on a notification. The three checks stay in their original order. Clearing it takes stop then start, and the body says so.
- Figures left the bodies of the frozen-meter card (the rail and the command), the circulating-power card (the watts and the time window) and the peak cards (the projection). None of them is needed to act, and all of them are still in the reason text that the log line and the status row carry.
test_freeze.pyandtest_watchdog.pynow pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none. - The over-rating alarm keeps its numbers (asked, sent, and which limit cut it), because they are what a fault report needs.
ClampWatchdog.factscarries them under the same keep-the-bug rule asreason, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40,test_clamp.pypart 6). - SAFETY-25 stays in the peak-reserve alarm as its last sentence ("the reserve only limits peak shaving … down to its cut-out level"). The alarm now gives each branch its own action: at the reserve, lower it; battery level unread, check the sensor; floor unknown, set the cut-out level. That last branch is
soc_floor is None, which happens when an inverter's cut-out level is missing, not the peak reserve (that option always has a value). Before, every branch said "lower peak_soc_floor". The title, "Battery stopped limiting the peak", is true on all three branches. - SAFETY-32 at a roster of one. The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
- Dropped as background, now in DOCS: "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
- The Details link opens the add-on's documentation page (
/hassio/addon/<slug>/documentation, with the slug taken from the container hostname) and names the section. A#headinganchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
Phase 2b: status rows and page banners
Every row of the Commissioning list and every banner is now in app/messages.py (row_*, banner_*), and test_messages.py checks a rendering of each. The row and banner kinds are gone from ux08_allowlist.txt (200 → 168 entries). DOCS.md "Status page" explains the rows. Unchanged: every row's colour (ok / warn / bad), which rows show when, and the banner cascade order.
Where a choice was made
- A red row may use 3 sentences (30 words). STYLE.md gave rows 15 words; the owner allows red safety states 3 sentences. A red row needs room for what happened, the state (0 W) and how it clears. STYLE.md now says so, and the test applies it by colour.
- Figures left the rows, not the product. The reason texts (freeze, sign, circulating, delivery, peak guard) carry reason codes and limits; they stay on the ERROR/WARNING log line and the event list, as before. Rows keep the numbers a reader acts on: the stall age and the 0 W deadline, the meter age and limit, asked and sent for an over-rating, the peak limit, and "opposite for 12 of 60 seconds" while a check counts. So 2a item 2 ("also on the status row") no longer holds, and DOCS.md says "in the add-on log".
- No entity id on any row, even the green ones that used to print
sensor.x = 54. The sensor rows name the setting (Battery power for inverter-b). Solar sources are numbered in the order of the settings list (source 2 of 3, entry 1 of the reversed-sign list). This retires TEL-12's rule that a one-entity site reads 0.11.0's sentence byte for byte. Atools/tel11_pv_trace.py --compareagainst a pre-UX-08 tree therefore shows the PV rows as changed telemetry, and the control documents stay equal. The per-source watts and units left the multi-source row. The total, the count and which source is missing remain. - UX-07 item 1.
ClampWatchdognow also countsover_ratingandboundseparately. After an episode, each count shows under its own alarm's title ("Power request too high: 2 times since start, not now", "Battery is not keeping up: …"). A site with both gets two amber rows where it had one. - The "Not ready" banner is the first red row's own
banner, or else its first sentence. The peak-reserve and over-rating rows setbannerto their notification title. The three trips keep their own banner branches, reworded (e.g. "Inverters worked against each other. Every inverter is set to 0 W."). - How a latch clears stays on the row, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
- Rows got an
id(meter_frozen,direction,pv, …), so tests find a row by what it is, not by its words. The page ignores it. - Left for later: texts that rows embed from other modules and that were not flagged as rows (the month-peak line,
peakguard.explain(), the liveness and stale notes,shadow.banner()). The month-peak row only lost its semicolons.
UX-09: fixes after the phase 2a test
- The Details link follows the Home Assistant version. HA 2026.2 added the built-in
apppanel (core #157554, frontend #28245), whose add-on pages are/config/app/<slug>/documentation. 2026.5 stopped registering thehassiopanel, so/hassio/addon/<slug>/documentationis a 404 there (TEST-23, HA 2026.9.1). 2026.1 and older have only the old path. The one path valid on both,/_my_redirect/supervisor_addon?addon=<slug>, opens the Info tab, not Documentation. So the add-on reads its slug (GET /addons/self/info) and the HA version (GET /info) at start, and picks the path. An unknown version gets the current path. The hostname stays as a fallback for the slug. Phase 2a item 7 is replaced by this. - Shadow mode says "would". The over-rating alarm and row said "sent X W" while 0 W was on the wire, and the peak alarm said "the battery is giving all it can". Both now take
shadow=, and in shadow mode say what would have been sent and that the inverters stay at 0 W. Severity, id and cadence do not change with the mode. - "Controller" is gone from texts. "The add-on" names who decides, and "control" names what it does, as in the existing "start control". The add-on is what the reader sees in Home Assistant, and "controller" was easy to confuse with the control board.
- One term per setting, matching its label. Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
- The shadow banner and the inverter notes are now
messages.banner_shadow()androw_no_setpoint/row_not_controllable/row_commands_refused, sotest_messages.pychecks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes fromis_live(), not from matching "NOT COMMANDABLE" in the text. - DOCS "Notifications" and "Status page" are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
UX-08 phase 2c: refusals, error texts and option texts
Validator refusals, the parse errors they carry, and every option name and description are now short. No rule, key, schema, default or exit code changed. Over 36 spoiled configurations the old and new validators refuse the same number of times. The allowlist went from 167 entries to 43, and only log lines (phase 2d) remain on it.
- A refusal names the setting by its label and then its key, as
messages.setting()writes it: “Inverter rating” (max_w). The key has to stay, becauseconfigui.problems_by_field()shows a reason beside every key its text names. A test checks each label against en.yaml. A few reasons no longer mention keys they only named in passing, such asprice_markup_eur_kwhin the withdrawn-provider reason, so the panel shows those reasons beside fewer settings. Each reason still names the setting that fixes it. A setting of one inverter in the list is written “Inverter rating” for inverter-b (inverters.max_w), so the panel shows it besideinverters, where that value is changed, and not only beside the flatmax_wthat a list does not read. - Citations moved to DOCS. Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in
test_translations.pynow checks the tab for that instruction and DOCS for the record. - What left the refusals: the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind
slew_w(DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change. - Not rewritten: errors that only a code defect can raise, such as plugin-contract and argument-type checks in
price.py,price_forecast.py,static_tariff.py,arbitrage.pyandmain.py, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
UX-08 phase 2d: log lines, the rest of the status page, and DOCS
The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons (the semicolon rule now covers log lines too). Unchanged: every log level, every trigger and cadence, every notification id, every row's colour and when it shows, the banner cascade, and every refusal count.
- 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 grepsOUTPUT_CLAMPandEngagements so far.OUTPUT_CLAMPis 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. - Reason texts keep their codes. A line quotes a watchdog's reason (
%s), such asSTALE_READING: …orOUTPUT_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. - 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".
- The line under the buttons showed the newest raw action-log entry (
OUTPUT_CLAMP …,FAILSAFE …: SIGN_DISAGREEMENT,ALLOCATOR …). Eachlog_event()call now also names a plain hint frommessages.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 aseventsin thestatusJSON (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. - 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.reasonkeepsResolution.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. - 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. - 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.
- Wording that changed meaning-carrying words: "command and meter point opposite ways" is now "the power went the wrong way" (banner) and "…went against what the add-on asked" (row). The N=1 banner still names the meter, the N≥2 texts still say "own power". The inverter range reads "up to 5 000 W each way" instead of "-5 000 W to 5 000 W".
- Refusals are counted as shown. Setting placeholders are expanded to label and key, and the long cases the 2c test found are driven through the validator. Seven refusals were shortened. The card refusals no longer show
c_eur_per_kwh,vat_pctor "its own card", and a text that does not parse says "not valid near character N" instead of the parser's words. A missing value shows as empty, notNone. - The panel's key match no longer files
inverters.max_wunder the flatmax_w. - The Supervisor question at start is one INFO line with a real reason (a timeout's own text is empty, so the error type is named).
- DOCS: a glossary ("Words used on this page") explains control board, isolator and Developer Tools once. Card ids left the headings and sit on a For installers line under each. The links to the renamed headings were updated.
UX-10: the last plain-language leftovers (after the 2d test)
Unchanged: every safety behaviour, every alarm's log level (WARNING and ERROR stay), every notification id, trigger and cadence, the banner cascade, and every row colour except the meter row right after a start (item 6).
- Why the 2d scan missed the clamp lines. Part 3 reads a log call's own literal and counts
%sas one word. The clamp lines are"…software fault: %s …", and the text in%swas built elsewhere, byloop_clamp_note()andClampResult.describe(). So the scan passed a line whose logged text had a semicolon, "BUG SIGNAL", "clamped" and "1 times". The same was true of every quoted reason. Part 6 closes the gap. It lists every dynamic part of every WARNING+ line as data (a number, a name, a path, a library's own error text) or as a built text, and a new part in neither list fails. Each built text is produced by the real code (the 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 line as logged are then checked. A mutation shows the 2d clamp text is caught on both clamp lines. - A quoted reason gets the red-alarm shape, 3 sentences of at most 30 words, and not the 25-word line limit. It is the evidence behind an alarm, and it keeps its code and its limits, which the owner asked to keep. The alarm shape is STYLE.md's existing rule for the texts behind a safety stop, so no new limit was made up. Rewritten:
OUTPUT_CLAMP(both kinds),METER_FROZEN,CIRCULATING_POWER,STALE_READING(SAFETY-56's two explanations and "cannot tell which" kept, in order), the peak guard's reasons, the state-migration report, the price feed and price history reasons, and the maintenance and trading gate reasons. "Software fault" left the clamp reason, because the ERROR line around it already says it. - Codes are one token.
PEAK GUARD,PEAK GUARD FLOOR-LIMITEDandPEAK GUARD BLINDread as capitals, so they are nowPEAK_GUARD,PEAK_GUARD_FLOOR_LIMITEDandPEAK_GUARD_BLIND, likeOUTPUT_CLAMP. Nothing in admin/goodwe or in the scripts under/home/homelabgreps them (checked)./home/homelab/clampwatch.shgrepsOUTPUT_CLAMP, which stays. - Top table. Price history writes "96 prices over 24 hours for 26 Sep", joined by commas. Tariff writes the spacing as "one every 15 minutes or every hour". "Not final yet" was dropped, because a plan is provisional exactly when it holds an estimated price (
n_carriedcounts the provisional points), so the estimated count already says it. - Levels and noise. The shadow line of every cycle is DEBUG, and "Shadow mode is on/off" is INFO: it follows a setting or a button, and the banner, hint and event are unchanged. aiohttp's access log is on only at log level debug. A settings warning is logged once per start (a filter on
goodwe.config, WARNING only, so refusals are untouched), because the validator runs twice at start. When Home Assistant restarts,hass.Outagesays so in one WARNING line for the REST API and the meter websocket together, puts the library's text on DEBUG, and says at INFO when both answer again. INFO lines lost "§8.3", ISO timestamps, "banked" and "keys ARE", and error texts lost "NOT_OBSERVED", "did NOT land" and "AND". - Display bugs.
- "Why: Other" during a latched per-inverter stop:
main.pyturns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry inWHY_WINNER. The mapping is nowmain.why_key(), and part 7 finds everyClaim.set()in the code and checks that every key it can produce has words. - Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives,
P1Ingest.staleis true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the 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. Amber, not green, because control waits for the reading. Past the limit it is red as before.staleitself, which gates control, is unchanged. - "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop 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 15 s escalation are unchanged.
- "Why: Other" during a latched per-inverter stop:
- Witness (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.