Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 b171ada894 Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
checks / unit (pull_request) Successful in 23m54s
infra-pin / fresh (pull_request) Successful in 1s
Conflicts: modetrade.decide() takes both the day plan (targets) and
SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle
ahead of the plan's word (test added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:37:09 +02:00
glenn schrooyenandClaude Opus 5.5 0b96e3191c OPT-11 P4 review: records and text
checks / unit (pull_request) Failing after 4s
infra-pin / fresh (pull_request) Successful in 1s
- DECISIONS: the Cheap hours change and why; the no-sun replay (the gain is
  Leftover solar buying the evening's shortfall; the forecast added 0.14
  EUR); the reconstruction bias stated plainly.
- DOCS: Cheap hours ignores the forecast; a reading from before midnight
  is stale.
- The forecast line: "Solar still to come today: ..." (it is what is
  left, and it stays within the 12-word limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:29:00 +02:00
admin 35cdffe766 Merge pull request 'SAFETY-78: maintenance drain at N=2 no longer stalls' (#154) from safety-78-drain-at-two into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-29 22:20:29 +02:00
glenn schrooyenandClaude Opus 5.5 a692560960 Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:52 +02:00
glenn schrooyenandClaude Opus 5.5 fbd884797d OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the
  design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
  2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
  integration refreshes, "left today" is still yesterday's 0 and
  "tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
  /api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
  dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
  no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
  after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:47 +02:00
goodwe-review 516bc8b4ec Merge release/1.0 into safety-78-drain-at-two
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:53:47 +02:00
admin 4f25b24aa6 Merge pull request 'SAFETY-81: no false Load too high alarms' (#155) from safety-81-load-too-high into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-29 21:45:19 +02:00
goodwe-review 044fc0d5bd SAFETY-78 review: card truth while the drain waits, more checks
checks / unit (pull_request) Successful in 16m15s
infra-pin / fresh (pull_request) Successful in 1s
- homeview: no "This one does not charge" while the draining battery waits
  (no floor then, so the other battery may charge from solar).
- test_safety78: one wait line across a probe, a second episode logs again,
  the card while waiting, and a seeded sweep of the floor against every
  other claim (part 6).
- DECISIONS: the wait case, and the measured N=1 peak and N=2 charge
  follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:13:31 +02:00
adminandClaude Opus 5.5 f967073cfe SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
checks / unit (pull_request) Successful in 20m31s
infra-pin / fresh (pull_request) Successful in 2s
- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
  clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
  quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
  digests; control still byte-identical. Unit checks for export and the
  hold across a missing reading. test_peakguard's saturated fixture moves
  the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
  double count in control left to a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:58:37 +02:00
Claude aabc2a27fd OPT-11 P4: tests for eight surviving mutants; guarded reads
checks / unit (pull_request) Successful in 23m10s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:16 +02:00
Claude af39c10893 SAFETY-78: test_safety78 points at MUTATION.md (test_bytecode)
checks / unit (pull_request) Successful in 15m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:14:33 +02:00
goodwe-agent 6eb67c0f9c SAFETY-81: no false 'Load too high' alarms
checks / unit (pull_request) Successful in 16m11s
infra-pin / fresh (pull_request) Successful in 1s
The alarm now also needs peakguard.load_too_high(): with the battery at
full power, the quarter still ends more than 10 W over the real limit.
The battery reading is the lower of this cycle's and the last, so a meter
sample from before the battery's last increase is not counted twice.
At most one alarm per quarter. Control outputs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:55:38 +02:00
Claude 5d27f8496f SAFETY-78: maintenance drain at N=2 no longer stalls
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s
At two inverters the drain pins its pack and the site target is split
around the pin. Below the pin the other pack had to charge, the allocator
refused the opposite directions, and the site sat at 0 W with an ARBITER
line every cycle (house below or above the drain rate, or a grid charge).

main.py now files a site limit `drain` (lo = what the pin places, at
P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds
power in the same direction. A drain that cannot give power waits with
one log line. With battery modes on, no grid charge while a pack drains,
and the other battery's card says why. N=1 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:52:51 +02:00
Claude eb10537aee OPT-11 P4: solar forecast and day plan
A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo
shapes) and the add-on's own hourly house use decide, before each cheap
run, whether the sun will fill each battery before the expensive hours.
Leftover solar then buys from the grid only the shortfall; Cheap hours only
skips the part the sun covers; Solar first never grid-charges. No, stale or
failed forecast: P3 option A exactly (12 digests pinned from 5a4cdc2).

Replay (16 recorded days, forecast errors from Elia's day-ahead BE solar):
year-earlier prices P4 19.15 vs P2 19.33 / option A 19.44 / old trade 18.14;
real 2026 prices P4 29.19 vs 32.26 / 32.01 / 31.40 EUR net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:29:32 +02:00
admin 5a4cdc273e Merge pull request 'OPT-11 P3: price runs per battery' (#153) from opt11-p3-price-runs into release/1.0
infra-pin / fresh (push) Successful in 2s
OPT-11 P3: price runs per battery (owner option A: selling off, Leftover solar solar-only until P4) (#153)
2026-09-29 11:37:46 +02:00
glenn schrooyenandClaude Opus 5.5 7104515229 OPT-11 P3 option A review: wizard no longer says modes come later
checks / unit (pull_request) Successful in 15m14s
infra-pin / fresh (pull_request) Successful in 1s
Step 5 said "Choosing a mode for each battery comes in a later update",
stale since P1. It now points to battery modes on the Batteries screen
(EN/NL); test_setup checks the new line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:21:32 +02:00
Claude dev agent 6dab4ec16b OPT-11 P3, owner option A: selling off, Leftover solar takes solar only
checks / unit (pull_request) Successful in 15m12s
infra-pin / fresh (pull_request) Successful in 1s
Owner decision 2026-09-29. Selling is off until it can be judged on the price
the grid pays: packmode.SELLING_ENABLED = False makes read() answer
sell=False whatever the store holds, pack_sell is refused ("Selling is off
for now."), and the page has no selling switch; the sell code stays. Until
the P4 forecast, only Cheap hours only charges from the grid
(packmode.GRID_CHARGE_MODES); Leftover solar holds and gives in the
expensive runs. Help, Today and plan lines, DOCS and DECISIONS follow.
Replay, 16 days: Solar first + Leftover solar 19.44 EUR net (P2 19.33).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:42:58 +02:00
glenn schrooyenandClaude Opus 5.5 b014fd2e16 OPT-11 P3 review: sale shows no money, honest sell text, tests for four surviving mutants
checks / unit (pull_request) Successful in 14m5s
infra-pin / fresh (pull_request) Successful in 1s
- A sale block on the plan no longer says "Worth up to" at the import
  price: a sale earns the lower injection price.
- The selling switch help (EN/NL) and DOCS say the grid pays less than
  power costs; DECISIONS records why P3 loses on the replay.
- test_modes_p3 part 11: modes on ignore the site trade switch (R13), a
  battery in maintenance takes no part (R10), a headroom stop latches for
  the period (R8). All three mutants survived before and are red now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:51:15 +02:00
Claude dev agent 902524ae5e OPT-11 P3: price runs per battery
checks / unit (pull_request) Successful in 13m45s
infra-pin / fresh (pull_request) Successful in 1s
Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:37:44 +02:00
adminandClaude Opus 5.5 7a00f12c76 SAFETY-79: peak guard keeps a margin below the limit (#151)
infra-pin / fresh (push) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:01:35 +02:00
Review AgentandClaude Opus 5.5 ce0aea30b6 SAFETY-79 review: margin under a banked month peak too; identity harness reaches headroom and maintenance
checks / unit (pull_request) Successful in 11m50s
infra-pin / fresh (pull_request) Successful in 1s
- quarter_target_w() is now the margin under quarter_ceiling_w(): a peak
  banked between cap - margin and the cap was aimed at exactly, so one
  overshoot crossed the cap; above the cap the same overshoot raised the bill.
- safety79_identity.py hashes charge_headroom_w() and the shaping suspension,
  and adds n1_maint/n2_maint (cheap window, forecast across cap - 50 .. cap).
  Digests re-printed on ed739d4 and pinned; margin 0 still identical.
- test_margin_under_banked_peak; DECISIONS and DOCS updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:49:16 +02:00
Review AgentandClaude Opus 5.5 e6096d3e17 Merge release/1.0 (7337b79, OPT-11 P2) into safety-79-peak-margin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:23:14 +02:00
admin 7337b791e5 OPT-11 P2: HA entities for battery modes (#152)
infra-pin / fresh (push) Successful in 2s
2026-09-29 00:13:49 +02:00
glenn schrooyenandClaude Opus 5.5 308adc9277 Merge release/1.0 (7995e4d, UX-13 P3) into opt11-p2-ha-entities
checks / unit (pull_request) Successful in 11m29s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:58:39 +02:00
adminandClaude Opus 5.5 c664e38ef4 OPT-11 P2 review: refusals stay counted, removals survive a start with no broker
- A refusal past the log-once set or the 32 cap is counted in the log at
  1, 10, 100 and so on, so a repeated attack stays visible.
- The logged topic is printable only (no forged log lines).
- mode_announcement(): the start-up store logic, now tested with a real
  Store. A removed config topic stays stored, so a start with no broker
  cannot forget it (rename case tested).
- A test ties each HA mode option to the page's English name.
- packmode.enabled() is tested for "true", 1 and "false".
- Retained-command text, DOCS and DECISIONS updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:58:34 +02:00
glenn schrooyen 0951cb227f Merge release/1.0 into safety-79-peak-margin
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 11m36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	goodwe_controller/app/uilayout.py
2026-09-28 23:56:33 +02:00
admin 7995e4df0f UX-13 P3: Simple view and Batteries screen (#150)
infra-pin / fresh (push) Successful in 2s
2026-09-28 23:44:26 +02:00
glenn schrooyenandClaude Opus 5.5 e5a0ba23d7 SAFETY-79: CI fixes - MUTATION.md pointer, no key-taking helper, fixture sized on the aim
checks / unit (pull_request) Successful in 11m36s
infra-pin / fresh (pull_request) Successful in 2s
test_peakguard's over-limit fixture now sizes its load against
cap - margin, so the floor it asks for is exact wherever in the quarter
the suite runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 23:37:55 +02:00
goodwe-review-agent a9f7d78527 UX-13 P3 review: truthful peak lines, a clock-proof test, Simple stays default
checks / unit (pull_request) Successful in 11m10s
infra-pin / fresh (pull_request) Successful in 2s
- The peak limit's Why and plan block need a real power request (floor above
  0 W), not a blind or floor-limited guard. An Off battery gets the peak
  block only while it gives power. Why no longer says every battery gives.
- test_batteries_screen pins the fixture clock: unpinned it failed every
  morning. New checks cover the trade and cycle filters, the blind guard and
  an Off battery that A covers alone.
- Settings keeps only a picked view; a link to Installer is not remembered.
- Browser check: badges, unsaved change across views, locked value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:20:33 +02:00
adminandClaude Opus 5.5 b310282729 OPT-11 P2: HA entities for battery modes
checks / unit (pull_request) Successful in 11m12s
infra-pin / fresh (pull_request) Successful in 2s
Each battery gets an MQTT-discovered Mode select and Use switch, only while
battery_modes_enabled is true. Commands go through handle_action(), the
/action path; refusals are logged once. Retained commands are never applied.
Turning modes off removes exactly the configs announced (kept in the store).
With modes off the announcement is byte-identical to ed739d4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:12:04 +02:00
glenn schrooyenandClaude Opus 5.5 7c4c8f5c66 SAFETY-79: peak guard keeps a margin below the limit
checks / unit (pull_request) Failing after 11m34s
infra-pin / fresh (pull_request) Successful in 1s
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.

peak_margin_w 0 is byte-identical to ed739d4 (tools/safety79_identity.py,
test_peak_margin.py part 1). validate.py rule 12 refuses a value outside
0-500, not whole, or not below the peak limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 23:09:39 +02:00
Claude 2d2b6afb07 UX-13 P3: Simple view and Batteries screen
checks / unit (pull_request) Successful in 11m9s
infra-pin / fresh (pull_request) Successful in 2s
- Batteries: one card per battery with On/Off, the mode list (cheap hours
  and selling greyed out until OPT-11 P3), level, and Now / Today / Why
  lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses
  the existing pack_use / pack_mode actions and store keys.
- An Off battery the peak limit still needs says so with its figure; no line
  shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned
  yet" until OPT-11 P3/P4.
- Home's plan leaves Off batteries out, except for the peak limit.
- Settings opens on Simple: the layout's simple fields with an applies chip
  and a note, plus the battery switches and modes. Installer is one tap.
- English and Dutch words in app/ui_text; test_batteries_screen.py; browser
  check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and
  changed screenshots only.

No control, allocator, arbiter or command module changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 22:39:43 +02:00
adminandClaude Opus 5.5 ed739d4cdb OPT-11 P1: per-battery mode limits, on/off, solar order (#149)
infra-pin / fresh (push) Successful in 2s
Reviewed: a mode gives way to the peak floor only as far as it needs; SAFETY-03 kept at the rail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:53:22 +02:00
glenn schrooyenandClaude Opus 5.5 ffc9a4d721 OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s
- arbiter: a mode now gives way only as far as a site limit above P_MODE
  needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
  released only when the peak floor bound S, so a trade won above the floor
  kept them and placed less than the floor (an Off battery at N=1 went to 0 W
  under a 2000 W floor), and a capped grid charge released an Off battery.
  While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
  rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
  (peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
  loop bound counts live units only). DOCS and DECISIONS say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 19:41:36 +02:00
Claude dev agent d69fd8fbfd OPT-11 P1: per-battery mode limits, on/off, solar order
checks / unit (pull_request) Successful in 10m59s
infra-pin / fresh (pull_request) Successful in 1s
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.

compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).

Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:51:29 +02:00
admin a5dddadf45 UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor (#148)
infra-pin / fresh (push) Successful in 2s
2026-09-28 17:06:18 +02:00
review-agent ede01b8a04 UX-13 P5 review: every STYLE rule has a mutation check, fuller jargon lists, Dutch wording, overlap partner test
checks / unit (pull_request) Successful in 10m44s
infra-pin / fresh (pull_request) Successful in 1s
- test_ui_text.py: key parity, placeholder parity, English-left-in-Dutch,
  semicolon, 25-word description and 2-sentence line each now fail on a
  spoiled input (the English-left check was not a real mutation).
- Jargon lists now cover the whole 'Not' column of STYLE.md section 4, in
  English, with the Dutch equivalents.
- nl.json: 'gewijzigd worden' where 'wijzigen' read as intransitive,
  'Bezig met bewaren' for the saving state, 'Metingen kloppen niet'.
- test_uimodel.py: an overlap that changes partners is named piece by piece
  (a surviving mutant of _tiling's partner check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:54:45 +02:00
Claude dev agent 667b8e9145 UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:32 +02:00
adminandClaude Opus 5.5 8c34647d05 Merge pull request 'UX-15: setup-only mode, and wizard fixes F1, F2, meter entity, step 1' (#147) from UX-15-setup-only into release/1.0
infra-pin / fresh (push) Successful in 1s
Reviewed and live-tested on TEST-23: 0 service calls in setup-only mode; review comment 1896.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:27:27 +02:00
Claude review agent 39e6c260fe UX-15 review: DECISIONS states the TEST-23 run that happened (PR #147)
checks / unit (pull_request) Successful in 11m8s
infra-pin / fresh (pull_request) Successful in 1s
It said the PR had TEST-23 evidence, but the PR says TEST-23 was not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:15:51 +02:00
Claude review agent 30cba1e58d UX-15 review: shorter setup banner (PR #147)
The Home banner title was ten words; UX-08 asks for about six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:06:12 +02:00
adminandClaude Opus 5.5 63e7e8a133 UX-15: mutations fail named checks, not crashes
checks / unit (pull_request) Successful in 10m38s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:54:26 +02:00
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00
admin 4de96752ff Merge pull request 'UX-13 P4: the setup wizard' (#146) from UX-13-p4-setup into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-28 06:23:16 +02:00
review-agent 3198f346fd UX-13 P4 review fixes (PR #146, comment 1887)
checks / unit (pull_request) Successful in 10m24s
infra-pin / fresh (pull_request) Successful in 1s
R1: first_start also needs a Store created at this boot (Store.fresh), so
    old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:12:09 +02:00
adminandClaude Opus 5.5 be57b87c34 UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00
admin 515c11b380 Merge pull request 'UX-13 P2: the Installer settings view' (#145) from UX-13-p2-installer into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge UX-13 P2: the installer settings view (#145)
2026-09-28 03:54:23 +02:00
UX-13 P2 review 7be09197d4 UX-13 P2 review fixes (PR #145, comment 1880)
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s
1. Discard reloads /config/model: after a stale save the page kept the
   new fingerprint with the old values, so Discard + Save overwrote the
   other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
   flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
   while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
   "Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
   peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
   static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 03:43:47 +02:00
Claude 1752adf6e6 UX-13 P2: test_settings reads fields through named_absence
checks / unit (pull_request) Successful in 10m15s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 03:02:43 +02:00
glenn schrooyen 0a28f71872 Merge release/1.0 (28a49cc, SAFETY-73) into UX-13-p2-installer 2026-09-28 02:49:58 +02:00
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 02:49:58 +02:00
admin 28a49ccf75 Merge pull request 'SAFETY-73: a battery that refuses, per inverter, with two or more' (#143) from SAFETY-73-per-unit-refusal into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-28 02:27:15 +02:00
glenn schrooyenandClaude Opus 5.5 06aa09e28c SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 10m11s
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
  LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
  units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
  ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
  stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
  the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
  U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:16:24 +02:00
glenn schrooyenandClaude Opus 5.5 1a9c7523c9 Merge release/1.0 (a36dca6) into SAFETY-73-per-unit-refusal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:41:54 +02:00
admin a36dca60dd Merge pull request 'UX-13 P1: page shell and Home screen on a structured /status' (#144) from UX-13-p1-home into release/1.0
infra-pin / fresh (push) Successful in 1s
UX-13 P1: page shell and Home screen on a structured /status (#144)
2026-09-28 01:29:30 +02:00
adminandClaude Opus 5.5 fad73d4012 UX-13 P1 review: Home draws only what the cycle would do
checks / unit (pull_request) Successful in 9m36s
infra-pin / fresh (pull_request) Successful in 1s
- Trade blocks: none on a battery with no room, no sale at a negative price
  or while the emptiest battery is at arbitrage's floor, nothing when a
  level is unknown. A give after a charge may use what the charge put in.
- The refusal line names the real reason: prices not published vs gap.
- The plan's peak line uses the guard's ceiling (this month's peak once
  above the cap), as the peak card does.
- House below zero: 0 W inside the idle band, no figure beyond it.
- Price bars are "Low price" / "High price": the add-on trades in one hour.
- Control toggle: one request in flight at a time.
- test_home: the new cases; the house-sign mutation now goes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:18:33 +02:00
glenn schrooyenandClaude Opus 5.5 5a8d832eba Merge release/1.0 (afd6af0) into SAFETY-73-per-unit-refusal
checks / unit (pull_request) Successful in 9m58s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:16:54 +02:00
Claude f8d63cc614 UX-13 P1: page shell and Home screen on a structured /status
checks / unit (pull_request) Successful in 9m37s
infra-pin / fresh (pull_request) Successful in 2s
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
  Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
  the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
  month_peak and control beside the old fields. The plan is what the add-on
  does today: the arbitrage verdict (the same plan.arbitrage() call as the
  cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
  hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 00:45:01 +02:00
glenn schrooyenandClaude Opus 5.5 016ed5a5a3 SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.

test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:13:26 +02:00
admin afd6af068d UX-13 P0: layout table, page<->options derivation, warning tier, entity list (#142)
infra-pin / fresh (push) Successful in 2s
2026-09-27 23:49:32 +02:00
adminandClaude Opus 5.5 badea3cb2d UX-13 P0 review: keep unread keys, back off a failed entity read
checks / unit (pull_request) Successful in 9m30s
infra-pin / fresh (pull_request) Successful in 1s
- uimodel: editing a listed inverter keeps the stored flat keys, and a
  region of "own" keeps a stored price_taxes_profile (both were dropped).
- haentities: a failed read also waits CACHE_S, so polls while Home
  Assistant is down do not queue one 10 s read each.
- ui_text: the Flanders preset names its DSO, and the help says
  Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card.
- tests: decode of a reversed solar row, both kept keys, the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:39:43 +02:00
glenn schrooyenandClaude Opus 5.5 15627c38a6 UX-13 P0: layout table, page<->options derivation, warning tier, entity list
checks / unit (pull_request) Successful in 9m32s
infra-pin / fresh (pull_request) Successful in 1s
The foundation for the new settings page. No visible UI change.

- app/uilayout.py: screen -> group -> part -> field for every option in
  config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display
  scale, step, entity kind, show conditions, Simple/wizard placement and
  "applies after a restart". Ranges stay in the schema, words in
  translations and the new app/ui_text/en.json.
- app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one
  inverter vs the list, solar rows, static_profile from simple prices, a
  year-stamped region preset for the levies figure, and an OPT-11 hook for
  arbitrage_enabled that changes nothing yet). Unchanged derivations write
  the stored values back, so the round trip is exact.
- validate.py: validate.Note, a warn/info tier. Each logged warning is also
  a note when a list is passed; config_notes() adds checks that need live
  data (missing entity, a kW sensor read as W, a limit below this month's
  peak). /config/validate returns them as "notes". No refusal changed: 261
  recorded validator calls, 0 differ (tools/ux13_validator_calls.py).
- GET ha/entities: Home Assistant's entity list for the picker, read through
  hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200
  with the old list marked old, or an empty list that says why.
- DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI
  code": keys may appear in uilayout.py and uimodel.py only, both tested.
- Tests: test_uilayout, test_uimodel (round trip over the recorded calls,
  the TEST-23 option files and config.yaml defaults), test_haentities, a
  warning-tier part in test_validate; test_messages scans notes and
  ui_text; test_pv and test_selfconfig part 7 widened on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 23:11:31 +02:00
admin 652097abde Merge pull request 'SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min' (#141) from SAFETY-52-refused-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 18:21:21 +02:00
adminandClaude Opus 5.5 3c0f2b9669 SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:42 +02:00
glenn schrooyenandClaude Opus 5.5 1d33265aac test_price: grade the Why row of the same render, not a second read of why_words
checks / unit (pull_request) Successful in 9m20s
infra-pin / fresh (pull_request) Successful in 1s
Since UX-10 the waiting sentence counts its seconds when it is read, so the
second read could cross a second boundary on a loaded runner ("1 second" in
the row, "2 seconds" in the check). CI run 953 on this PR failed that way;
test_price.py passes locally on the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:04:55 +02:00
glenn schrooyenandClaude Opus 5.5 1f97c7652f SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00
admin d1f7e3336e Merge pull request 'UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG' (#140) from UX-11-short-safety-lines into release/1.0
infra-pin / fresh (push) Successful in 2s
Merge UX-11: short safety log lines, figures to DEBUG and events (#140)
2026-09-27 14:31:05 +02:00
adminandClaude Opus 5.5 3c298af3d8 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>
2026-09-27 14:21:05 +02:00
glenn schrooyenandClaude Opus 5.5 c628099e69 UX-11: the meter WARNING names the limit only, as 'over 30 seconds'
checks / unit (pull_request) Successful in 9m4s
infra-pin / fresh (pull_request) Successful in 2s
TEST-23 showed 'No meter reading for 30 seconds, over the limit of 30
seconds': the age is 30.4 s, which rounds to the limit. The limit alone is
always true at the moment the line is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:49:17 +02:00
glenn schrooyenandClaude Opus 5.5 29837dfaa7 UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- Every WARNING/ERROR line that reported a safety reason (direction, delivery,
  stuck meter, stale reading, peak guard, circulation, over-rating) now ends in
  the reason's code (messages.reason_code()). The reason itself, figures and
  limits included, is on a DEBUG line right after it and in the action log.
- The reasons and state.json are unchanged, so an old state file loads and
  shows the same way: every saved reason opens with its code.
- test_messages part 6 checks every WARNING+ line at 25 words as logged; nine
  non-safety families are listed with a reason and capped. The byte pin in
  test_watchdog now pins the meaning, in order.
- The meter logs one WARNING per quiet episode, and INFO when back, so the red
  row's "the add-on log shows why" holds. The Why line past the hold names
  the missing reading. Action log: "25 engagements", "Shadow mode off: ...".

No trigger, latch, threshold, log level, notification id, cadence, row colour
or banner order changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:34:30 +02:00
admin c4032ebc21 Merge pull request 'CI: bump infra pin to d637d0b (admin/goodwe#61, UX-10)' (#139) from pin-bump-d637d0b into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 12:30:15 +02:00
glenn schrooyenandClaude Opus 5.5 2f1627e287 CI: bump infra pin to d637d0b (admin/goodwe#61, UX-10)
checks / unit (pull_request) Successful in 9m5s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:19:51 +02:00
admin 2b14b88cb4 Merge pull request 'UX-10: the last plain-language leftovers (log lines, two table rows, 'Why: Other', red at 0 s)' (#138) from UX-10-plain-leftovers into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 12:18:22 +02:00
adminandClaude Opus 5.5 e0b578756f UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-09-27 12:08:38 +02:00
glenn schrooyenandClaude Opus 5.5 f1a660881c UX-10: two INFO lines read better on TEST-23 (hours count, maintenance history)
checks / unit (pull_request) Successful in 9m2s
infra-pin / fresh (pull_request) Successful in 2s
"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>
2026-09-27 11:03:30 +02:00
glenn schrooyenandClaude Opus 5.5 23a3a3f5b5 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>
2026-09-27 10:58:43 +02:00
admin 125b0702a2 Merge pull request 'CI: bump infra pin to ecd3864 (admin/goodwe#60, UX-08)' (#137) from pin-bump-ecd3864 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 08:52:21 +02:00
glenn schrooyenandClaude Opus 5.5 b878663b2d CI: bump infra pin to ecd3864 (admin/goodwe#60, UX-08)
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 8m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:42:01 +02:00
admin 32b9e56357 Merge pull request 'UX-08 2d: plain log lines, the rest of the status page, and DOCS' (#136) from UX-08-2d-logs-docs into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 08:40:33 +02:00
Claude review 569f16aa91 UX-08 2d review: action log reachable in status JSON, engaged peak row pinned, imported keys expanded
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m54s
- 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>
2026-09-27 08:29:14 +02:00
adminandClaude Opus 5.5 6a95447248 UX-08 2d: plain log lines, the rest of the status page, and DOCS
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m55s
Every WARNING and ERROR line is one plain line of at most 25 words, with
no card ids, capitals or semicolons; the reasoning of the ten longest is
on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0).

The status page: the month-peak and peak-guard rows, the stale-reading
row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the
Stopped banner and the line under the buttons, which now shows the plain
hint of each event instead of the raw action log. The Supervisor question
at start logs one line with a real reason.

Refusals are counted as shown (labels expanded, long cases driven), seven
were shortened, the card and parse errors lost their internals, a blank
value reads "empty", and the panel no longer files inverters.max_w under
the flat max_w. Option names lose "(JSON)", the NL texts are checked too.

DOCS gets a glossary (control board, isolator, Developer Tools) and card
ids move from headings to a "For installers" line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 08:01:16 +02:00
admin a02cd4b713 Merge pull request 'UX-08 2c: short refusals, error texts and option texts' (#135) from UX-08-2c-refusals into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 07:01:25 +02:00
goodwe-review c84d5fd14f UX-08 2c review: unit settings anchor to inverters, plainer texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
- messages.setting() for one inverter in the list reads
  “Inverter rating” for inverter-b (inverters.max_w), so the config panel
  files per-unit reasons under `inverters`, where the value is changed.
- The tariff card and plausibility reasons name
  “Distribution, levies and VAT” (price_taxes_eur_kwh) again.
- The wrong-direction reasons say "safety stop", the sign_disagree_w
  option says what it protects, and the roster, price-entity,
  integrator_max_w and capacity_kwh texts read more plainly.
- NL: stuurprint -> stuurmodule, "De piekbeperking".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:51:57 +02:00
glenn schrooyenandClaude Opus 5.5 2c516a032c UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 05:55:45 +02:00
admin e1d0970b23 Merge pull request 'UX-09: Details link for current HA, shadow wording, plain terms' (#134) from UX-09-2a-fixes into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 04:54:42 +02:00
adminandClaude Opus 5.5 63dee6db1e UX-09 review: short start-up timeout, latch rows pinned in shadow, plainer rows
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 1s
The Supervisor questions for the Details link are asked together with a
3 s budget instead of one after the other at 10 s each, so a hung
Supervisor cannot hold control start for 20 s. In shadow mode the
circulating and site-direction latches are pinned to keep their own red
row that says how they clear. Plainer inverter and battery wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 04:45:06 +02:00
adminandClaude Opus 5.5 00e3ee9de8 UX-09: Details link for current HA, "would" in shadow mode, plain terms
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 8m50s
- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old
  /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and
  older. Slug from GET /addons/self/info, version from GET /info, hostname
  as fallback.
- Shadow mode: the over-rating alarm and row say what would have been sent,
  the peak alarm no longer says the battery is giving all it can.
- "controller" -> "the add-on" / "control". Settings named by their labels;
  four labels changed to match (Peak reserve, Battery level entity, Solar
  production ..., Reversed-sign solar entities).
- Shadow banner and inverter link notes moved to messages.py and checked.
- DOCS Notifications and Status page rewritten for home owners, with a
  "For installers" part each. Allowlist 168 -> 167.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 04:10:39 +02:00
admin d57767fe38 Merge pull request 'UX-08 phase 2b: short, plain status rows and page banners' (#133) from UX-08-status-rows into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 03:19:54 +02:00
glenn schrooyenandClaude Opus 5.5 571b884315 UX-08 phase 2b review: keep over-rating evidence, pin latch clearing, plainer rows
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
- During a bound episode an earlier over-rating count stays on the page (amber,
  under its own title); test_clamp pins it.
- test_messages pins that every latch row says how it clears.
- pv_entities description no longer claims the page names the entity (en, nl);
  allowlist hash updated for that option-desc line.
- Plainer texts: multi-inverter direction banner, "fine now" after an episode,
  and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py.
- TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:10:25 +02:00
glenn schrooyenandClaude Opus 5.5 696142bccc Merge branch 'release/1.0' into UX-08-status-rows
Brings in the infra pin bump (#132) so infra-pin / fresh runs against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 02:41:05 +02:00
adminandClaude Opus 5.5 d017fc67bf UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s
Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 02:12:10 +02:00
admin 5c151a3458 Merge pull request 'CI: bump infra pin to 7081df4 (admin/goodwe#59, UX-08)' (#132) from pin-bump-7081df4 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 01:41:37 +02:00
glenn schrooyenandClaude Opus 5.5 24f955cb26 CI: bump infra pin to 7081df4 (admin/goodwe#59, UX-08)
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:31:35 +02:00
admin 48ef06ac1f Merge pull request 'UX-08 phase 2a: short, plain notifications, and a ratchet for the rest' (#131) from UX-08-plain-notifications into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 01:29:46 +02:00
adminandClaude Opus 5.5 7b65a3e69a UX-08 review: tighter notification texts, and two meaning fixes
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m48s
- not delivering: the body said control keeps running and then "start
  control"; clearing it takes stop then start, and it now says so.
- peak reserve: the "reserve not set" branch is really soc_floor None,
  i.e. an inverter's cut-out level missing; it now names that. The title
  is true on all three branches ("Battery stopped limiting the peak").
- no semicolon run-ons in bodies (STYLE.md + test); loop-bound, circulating,
  meter-frozen and multi-unit over-rating bodies rewritten to match.
- allowlist cap is now exact, so a shrink cannot leave room to regrow.
- DOCS names the settings behind "peak reserve" and "cut-out level";
  DECISIONS no longer states the HA anchor behaviour as fact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:18:55 +02:00
glenn schrooyenandClaude Opus 5.5 50ccea3dd7 UX-08 phase 2a: short, plain notifications, and a ratchet for the rest
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s
Every HA notification the add-on raises now has a title of at most 5
everyday words and a one-sentence body; a red alarm may use three so no
action is lost. The texts live in app/messages.py, the rule in STYLE.md,
and the detail that moved out in DOCS.md "Notifications", which each body
links to. No notification_id, trigger or cadence changed.

test_messages.py enforces the rule on every notification, fails any
notification worded outside app/messages.py, and ratchets the kinds not yet
rewritten (rows, banners, refusals, logs, options) through a shrinking
allowlist of 200. Six tests re-pinned from prose to meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 01:00:54 +02:00
admin 2503de124e Merge pull request 'SAFETY-40: the integrator bound is not a BUG SIGNAL; alarm numbers from the clamp tick' (#130) from SAFETY-40-clamp-alarm into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-26 23:19:11 +02:00
glenn schrooyenandClaude Opus 5.5 55a6d5dcdb SAFETY-40 review: integrator-bound WARNING gets its own notification id
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
A later anti-windup notice could replace a write-path BUG SIGNAL notice
under the shared goodwe_output_clamp id (escalation ignores the budget).
The WARNING now uses goodwe_integrator_bound and points to DOCS; the
cadence sentence in DOCS is corrected. Tests pin both ids (red on fd14569).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:08:44 +02:00
glenn schrooyenandClaude Opus 5.5 fd14569a71 SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Successful in 1s
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG
SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed
`self.target` (after slew/freeze) as "was clamped to".

- clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its
  N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL
  with the requested command and the clamp's own result.
- Decision carries i_bound_w / requested_w / clamped_w, set at the clamp.
- i_clamped still counts and notifies (same id, cadence, SAFETY-41
  escalation) but as a WARNING, amber row, plain title. A bug has its own
  notification budget and its text is not overwritten within an episode.
- test_clamp.py part 9: red on the old code per defect; 5 mutations killed.
- DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-26 22:37:01 +02:00
admin 59415ea83f Merge pull request 'CI: bump infra pin to 7689816 (admin/goodwe#58, SAFETY-34)' (#129) from pin-bump-7689816 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-26 16:15:23 +02:00
glenn schrooyenandClaude Opus 5.5 f37268f3e4 CI: bump infra pin to 7689816 (admin/goodwe#58, SAFETY-34)
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:05:02 +02:00
admin 397dd15dba SAFETY-34: re-assert latched alarms after a Core-only restart (#128)
infra-pin / fresh (push) Failing after 1s
2026-09-26 16:03:16 +02:00
glenn schrooyenandClaude Opus 5.5 6028e21298 SAFETY-34: re-assert latched alarms, because a Core restart keeps the add-on
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s
A Core-only restart wipes persistent_notification and does NOT restart the
add-on (measured on TEST-23: the add-on's SAFETY-31 card was gone 4 min after
`docker restart test23-ha`, add-on StartedAt unchanged). The latched SAFETY-31
and SAFETY-42 cards were re-raised only on add-on start, so they stayed gone.

They are now re-asserted every REANNOUNCE_S = 300 s while tripped (same
notification_id, so it replaces). test_watchdog part 6 and test_delivery part
8b grade it with the same process across the "restart"; red on the old code.
The docstrings that said the add-on restarts with HA are corrected.
Record: goodwe docs/DECISIONS.md, SAFETY-34 D4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-26 12:37:47 +02:00
admin fe132515fe Merge pull request 'CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)' (#127) from pin-bump-a1d0618 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:52:59 +02:00
glenn schrooyenandClaude Opus 5.5 00e9a7b4e1 CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:42:44 +02:00
admin 82f032e8cf Merge pull request 'TOOL-9: pin checks print FAIL under pipefail; job tokens via header' (#126) from TOOL-9 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:39:47 +02:00
glenn schrooyenandClaude Opus 5.5 b963153de6 Restore .gitea/infra-pin (revert the TOOL-9 proof throwaway)
checks / unit (pull_request) Successful in 8m45s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:20:19 +02:00
glenn schrooyenandClaude Opus 5.5 a2c571b20e THROWAWAY (TOOL-9 proof, reverted next): remove .gitea/infra-pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:08:22 +02:00
glenn schrooyenandClaude Opus 5.5 550bd6fa52 TOOL-9: pin checks print their FAIL line under pipefail; job tokens via header
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Successful in 1s
A missing or comment-only .gitea/infra-pin now ends on the workflow's own
FAIL line instead of git's `fatal: path ... does not exist` (still red). Every
x-access-token remote URL becomes an env-set Authorization header, so no
token lands in .git/config or argv; the pin workflows' temp dir is removed
by an EXIT trap, also on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:07:34 +02:00
admin 6a8a5ffa27 Merge pull request 'CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe' (#125) from CI-12 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 18:19:43 +02:00
glenn schrooyenandClaude Opus 5.5 8ff42ea85d CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (2d026df, goodwe#56 merged)
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:10:28 +02:00
glenn schrooyenandClaude Opus 5.5 841424463a CI-12 review: INFRA_READ_TOKEN via an env-set header, not a remote URL
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
The long-lived token no longer lands in $WS/goodwe/.git/config (mounted into
the tests' container, left behind by a failed job) or in git's argv. A missing
secret (fork PR) fails with its own message. README: addon-pin is not re-run
after a bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:01:15 +02:00
glenn schrooyenandClaude Opus 5.5 c63eda0b0f CI-12: restore .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e)
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Successful in 1s
This reverts commit 54cc5263fb (the proof-2 throwaway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:45:36 +02:00
glenn schrooyenandClaude Opus 5.5 54cc5263fb THROWAWAY (CI-12 proof 2): pin one release/1.0 merge behind (d84ac01); reverted next
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 c493a6d67a CI-12: test_tariff.py no longer says CI cannot see the infra repo
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:27:06 +02:00
glenn schrooyenandClaude Opus 5.5 d0891a6bca CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e), restoring the proof pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:10:47 +02:00
glenn schrooyenandClaude Opus 5.5 fe80047235 CI-12 round 2: INFRA_READ_TOKEN for admin/goodwe; the pin tracks release/1.0
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
- checks.yml's infra checkout and infra-pin.yml's admin/goodwe ls-remote use
  the INFRA_READ_TOKEN secret; the self fetch keeps GITHUB_TOKEN.
- infra-pin.yml compares against admin/goodwe refs/heads/release/1.0; STALE
  message, comments and README follow.
- test_control.py / test_alloc.py: drop the claim that CI cannot reach
  admin/goodwe.
- .gitea/infra-pin is DELIBERATELY one release/1.0 merge behind (d84ac01) for
  the "infra-pin / fresh goes red" proof; the next commit sets it to HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:08:55 +02:00
adminandClaude Opus 5.5 6e3399322a CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe
infra-pin / fresh (pull_request) Failing after 1s
checks / unit (pull_request) Failing after 2s
- .gitea/infra-pin: the one place the admin/goodwe commit is pinned.
- checks.yml: checks out admin/goodwe at the pin as the sibling test_freeze.py
  looks for; any failure fails the job. Tests run with GOODWE_REQUIRE_HWSIM=1,
  and `CI: ` lines are echoed on PASS so the log shows which transport ran.
- test_freeze.py: GOODWE_REQUIRE_HWSIM refuses the stub; a local stub run
  prints a STUB USED line.
- infra-pin.yml: a separate, non-required check that goes red naming both
  SHAs when admin/goodwe master has moved past the pin.
- README: when and how to bump the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:57:04 +02:00
admin 16c796fa30 Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 2026-09-25 01:30:33 +02:00
admin 20260e86ec Merge pull request 'DOC-5: tell installers to turn on the Supervisor Watchdog' (#123) from DOC-5-watchdog into release/1.0 2026-09-25 01:17:35 +02:00
adminandClaude Opus 5.5 7fa045af85 TEL-14 N1: trim DECISIONS (drop probe transcript, §1 to the reason), move the mutation ledger there with M2/M5
checks / unit (pull_request) Successful in 8m44s
test_pv.py part 11 now prints a one-line pointer instead of the 12-row table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:11:40 +02:00
adminandClaude Opus 5.5 93f349a6af TEL-14 B1: pin stray caution on the reported case and whitespace normalisation end to end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:10:13 +02:00
glenn schrooyenandClaude Opus 5.5 d71f7dbd35 Merge release/1.0 (43b2740) into DOC-5-watchdog to sync base
checks / unit (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:08:15 +02:00
admin 43b274035f Merge pull request 'TEST-26: a missing value in a check is a named FAIL, not a traceback' (#122) from TEST-26 into release/1.0 2026-09-25 00:57:20 +02:00
glenn schrooyenandClaude Opus 5.5 6dbe06e32e DOC-5: add the Watchdog step to README's Install section
checks / unit (pull_request) Successful in 8m31s
One line after the install step, linking to the DOCS.md paragraph that
gives the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:30:44 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
admin 3e38bafcee Merge pull request 'SAFETY-62/SAFETY-60: operator tools refuse input they cannot read' (#120) from SAFETY-62-refuse-unread-input into release/1.0 2026-09-25 00:25:22 +02:00
glenn schrooyenandClaude Opus 5.5 02a822af98 DOC-5: tell installers to turn on the Supervisor Watchdog
checks / unit (pull_request) Successful in 8m41s
Since SAFETY-61 a dead control loop writes 0 W and exits 70. The Supervisor
restarts a stopped add-on only when the per-install Watchdog toggle is on,
and it defaults to off. One step in DOCS.md "Before you start", with a
pointer to the DECISIONS file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:20:52 +02:00
admin 6e6527224a Merge pull request 'SAFETY-61: the control task's death ends the process, after the wind-down' (#121) from safety-61-control-task-death into release/1.0 2026-09-25 00:07:41 +02:00
glenn schrooyenandClaude Opus 5.5 770988c902 TEST-26: a missing value in a check is a named FAIL, not a traceback
checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted
None killed the rest of their file (FCST-06). Read them through the new
named_absence.py (nth/first/field/given/present) and conjoin a presence term.

- every site in the brief, plus the family the new scan and a sweep found
- test_named_absence.py: scans every test_*.py for unguarded filtered-list
  indexing, next() without a default and numeric formats on maybe-None
  names; proven to bite; prints what it cannot see, counted
- test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the
  two assemble() calls' arguments
- test_control.py: coverage table re-measured on runs graded to the end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:06:58 +02:00
glenn schrooyenandClaude Opus 5.5 b06f1fad5f SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:02:33 +02:00
glenn schrooyenandClaude Opus 5.5 71d2d4a37a SAFETY-61: the control task's death ends the process, after the wind-down
checks / unit (pull_request) Successful in 8m22s
A done-callback on run_control()'s task logs the traceback at the death and
sets `stop`; amain() runs the ordinary wind-down (shutdown() ->
command_site_zero()) and main() exits 70 (EX_SOFTWARE). Normal stops and
run_control()'s refusal path are ignored by the callback (still 0 / 78).

hass.py: a malformed JSON body, a JSON non-object, and an undecodable error
body are an absence / a failed write, not an exception into the loop.

checks() gains a first "control loop" row, red past FIRMWARE_INGEST_VETO_S.

Why exit rather than restart in-process, and what the Supervisor actually
does on exit (restarts only with the per-install Watchdog toggle on, default
off): DECISIONS/SAFETY-61-control-task-death.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:52:42 +02:00
admin fa3d237ce1 Merge pull request 'DOC-1: operator docs name the real P1 entity and a roster that starts' (#119) from doc-1-operator-docs into release/1.0 2026-09-24 23:42:13 +02:00
glenn schrooyenandClaude Opus 5.5 f4f72211a9 SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:27:50 +02:00
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- DOCS.md / config.yaml: the watchdog's P1 age entity is
  sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
  app/mqtt.py announces and the live install recorded), not
  sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
  bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
  required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
  1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
  rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
  schema's per-entry keys; the documented entity id must equal the id
  composed from app/mqtt.py; the wrong id may appear in no shipped doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:24:50 +02:00
adminandClaude Opus 5.5 a45ed06c05 PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0 (#117)
checks / unit (pull_request) Successful in 8m0s
Reviewed at head 1ad4d3a. CI run 830 (attempt 1, pull_request, PR 117 -> release/1.0), 31/31 PASS. Review: #issuecomment-1588, #issuecomment-1589.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:09:28 +02:00
glenn schrooyenandClaude Opus 5.5 1ad4d3a5bb PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:59:44 +02:00
admin f863ecaa8c TEL-12: several PV inverters, and one absent member is an absent TOTAL (#116)
pv_entity becomes a roster: pv_entities beside it, replacing it, with the SITE total over the roster. One absent member makes the TOTAL absent - never the partial sum of the ones that answered, because a smaller number indistinguishable from a real one reads a week later as 'there was less sun than there was', which is the conclusion OPT-10 exists to test rather than to manufacture. A member honestly reporting 0 W is still summed.

Reviewed on PR 116. Re-run independently: supervisor_shape_probe.py SSG (exit 0, transcript reproduces byte for byte), the owner's real 75-key options blob through Supervisor 2026.08.0's own AppOptions (ACCEPTED, zero drift on any stored value, pv_entities defaulted to []), tel11_pv_trace.py --compare across both trees (every hash reproduces, control 846a10a5b89e unmoved on unset and one), the rejected-design mutant (trace REFUSED 5/5 with the partial sums named), the planted leak N8 (52/26/10 red cells), and eight mutations on fresh purged copies including N5 sum->max at +0 bytes - no survivors. Habit 24's central claim measured: TEL-11's own --compare refuses on every honest run now that release/1.0 is 0.11.0.

Green traced to this PR's own run: 829/1954, attempt 1, event pull_request, path checks.yml@refs/pull/116/head, payload naming PR 116 with base release/1.0 @ cccb2fa, 31 suites PASS in 7m53s - a real execution, not a laundered skip.

Not deployable as merged: config.yaml stays 0.11.0 and the CHANGELOG entry is PKG's call.
2026-09-24 22:41:19 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:13:25 +02:00
adminandClaude Opus 5 cccb2fa07b PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#114)
checks / unit (pull_request) Successful in 8m1s
Bumps config.yaml 0.10.1 -> 0.11.0 and adds the `## 0.11.0` section, so the Supervisor offers the update that carries SAFETY-56's wording fix and TEL-11's `pv_entity`.

Reviewed at head b11bea7. Green traced to this PR's own run (827, attempt 1, payload names PR 114 base release/1.0, 31/31 real). Version gate 11a-11d, SAFETY-56's behaviour hash and TEL-11's four trace rows all re-measured on this tree and matching. slug unchanged; option surface 75 -> 76 with no validator rule added.

Two non-blocking findings recorded on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 19:20:06 +02:00
adminandClaude Opus 5 b11bea70d3 PKG-17: the SAFETY-57 correction pointed at the wrong paragraph
checks / unit (pull_request) Successful in 8m13s
Self-review. The note added to `### SAFETY-57` said the reading rule it is
qualifying is "two paragraphs up"; it is in the paragraph directly above - the
same one the note follows. A changelog that misdirects a reader to a paragraph
that is not there is the small end of exactly the defect this cut is fixing
three sentences of elsewhere, so it is corrected rather than left.

Text only, one line pair, still two files in the branch's diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 19:01:35 +02:00
adminandClaude Opus 5 cfafdffaed PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1
checks / unit (pull_request) Successful in 8m0s
Two files, and no third. config.yaml 0.10.1 -> 0.11.0; CHANGELOG.md gains
`## 0.11.0` and three corrections to sections below it.

A MINOR BUMP, NOT A PATCH. TEL-11 adds an option - `pv_entity`, 75 keys -> 76 in
`options:` and `schema:` together - which is the same test 0.8.0, 0.9.0 and
0.10.0 were cut on. Nothing here can refuse a stored blob: the key is `str?`
with an `options:` default of "", validate.py does not mention it, no default
moved and no enum widened, so a blob that starts the add-on on 0.10.1 starts it
on 0.11.0.

THE TABLE HAS TWO ROWS AND THE WINDOW HAD FOUR MERGES. CI-9's follow-up
(9f6b9f1, `.gitea/workflows/checks.yml` only) and SEC-2 (239e120,
`docs/RELEASE.md` + README.md) are excluded under PKG-15's rule - AUDIENCE, NOT
IMPORTANCE. Neither file is under `goodwe_controller/`, neither is in the built
image, neither is in predeploy_check.py's RUNTIME tuple, and there is no version
of this add-on on which an operator can observe either. SEC-2 is the closer call
and the section argues it in full rather than asserting it: it is the more
consequential ticket of the two - it is why this release is a pull request
rather than a push to master - and it is still a procedure for whoever cuts a
release, not something the person deciding whether to press Update can act on.
Both are named in the section with pointers to PR #102 / checks.yml and PR #108
/ docs/RELEASE.md.

WHAT THE ENTRIES LET AN OPERATOR DECIDE. SAFETY-56: behaviour byte-identical
(6030175ed2ab..., reproduced in the CI image over 2390 capture reads, a 66-cell
grid stepped 120 times and six driven worlds), three surfaces named with the old
and new wording side by side - the STALE_READING line quoted onto the action log
and status page, the SAFETY-37 ERROR log line, and the readiness row that
asserted a false fact about a named entity. TEL-11: unset is byte-identical in
BOTH the control and telemetry documents, set is byte-identical in the control
document only; the three TEL-11 rows of that table were re-measured in the CI
image on this tree (control 846a10a5... twice, telemetry a443a6ce... / 4e6d1cb2...).
It is stated plainly that pv_entity is not a control input and does not change
what the loop does, and that absence publishes `unknown` rather than 0.

THREE CORRECTIONS BELOW THE NEW SECTION, WITHOUT REWRITING HISTORY. The 0.10.1
preamble said SAFETY-56 was open; the SAFETY-57 entry said its accusatory
diagnosis half was "deliberately untouched"; the 0.10.0 preamble left SAFETY-56
with no pointer while SAFETY-57 had one. All three quotations are what the
0.10.1 image actually prints and are left standing - a reader on 0.10.1 has to
recognise them - each now scoped to 0.10.1 and pointed at `## 0.11.0`.

VERIFIED. test_version_gate.py part 11 all four: 11a one contents table and the
slicer closed (302 lines under '0.11.0'), 11b 0.11.0 == config.yaml, 11c all
three RETIRED_PROVIDERS ids named - scoped to a pre-0.10.0 crossing and pointing
at `## 0.10.0`, which is where the evidence is - 11d still exactly one
`## Unreleased` below, PKG-08's, untouched. 31/31 in the CI image, checks.yml's
exact command, purged tree, `:ro`.

Not deployed and not installed. Shipping is a separate pull request from
release/1.0 into master, per docs/RELEASE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 18:50:13 +02:00
admin 94da37fc72 TEL-11: PV production stops being invisible, and stays out of the control law (#113)
Reviewed on the PR. Green traced to run 825 (PRNUM 113, BASEREF release/1.0, attempt 1, real 8m6s execution). Control-path identity independently reproduced on both trees and counter-tested with a planted leak (red on 26/41 cells); all three of the trace tool substituted refusals fire. M7 genuinely red. Three publication states distinct on the wire. 31/31 in the CI image.

Not deployable as merged: config.yaml stays at 0.10.1, so predeploy_check.py reports a NO-OP until PKG cuts a version.
2026-09-24 15:55:29 +02:00
glenn schrooyenandClaude Opus 5 07ba3433ed TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.

AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.

POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.

ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.

THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.

GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.

IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:

    control   release/1.0 unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      set    846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    telemetry release/1.0 unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      set    4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f

UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.

⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.

Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.

⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 15:35:07 +02:00
admin 9f6b9f14cd CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise (#102)
Corrects the preamble's claim that the `guard the trigger` step keeps checks.yml filter-free "mechanically". It does not, for blind spots 1 and 3: the guard lives inside the workflow a filter makes Gitea skip, so for those the defence is the comment.

Names the filter that would actually get written - `branches: [release/1.0]`, not `[main]` - because it reads as tightening onto the protected branch, leaves every day-to-day pull request green, and switches off the `release/1.0` -> `master` release pull request that SEC-2 made the mandated path to an installed system. Points the forge fix at both protected branches, not just `release/1.0`.

Reviewed on the pull request. Verified from the branch's bytes: the guard's awk is unchanged and still refuses `branches:`, `paths:`, `paths-ignore:`, `branches-ignore:`, `tags-ignore:`, `types:` and the inline-flow form injected into the `on:` block of the edited file; comment-only at 0 non-comment changed lines, non-comment lines identical 139 == 139; the merged tree differs from c9aaf7f in this one file only. Green traced per CI-10 to run 824 / job 1949, payload PRNUM 102 / BASEREF release/1.0, base c9aaf7f, 30 PASS 0 FAIL.
2026-09-24 14:37:15 +02:00
admin c9aaf7f04d SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can (#112)
Text only, no behaviour change. Three operator-facing sentences stop asserting a dead register where the gate cannot distinguish a dead register from a pack the BMS is refusing.

Reviewed on the PR: behaviour byte-identical (trace tool sha256 6030175ed2ab reproduced on both trees, plus AST identity with string constants normalised); five mutations re-applied on purged trees, all matching claimed named-red counts; SoC of the live episode independently measured at 11 percent; CI green traced to PRNUM 112 / BASEREF release/1.0.
2026-09-23 21:27:40 +02:00
adminandClaude Opus 5 8a6acd9fa2 SAFETY-56: the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can
checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING,
same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390
capture reads, a 66-cell swept grid, and six driven controller worlds produce
the byte-identical document `6030175e...` on origin/release/1.0 and on this
branch, while the sentences differ.

THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the
register has stopped reporting reality, or the SOURCE cannot comply with the
command - and nothing `freshness.py` is handed separates them. Three sentences
asserted the first. On the live 2026-09-21 episode it was the second: the pack
read 11 % for the whole 850 s hold, every command in that hold was a discharge,
and the register answered the first CHARGE command three seconds later. The
readiness row was worse still, because it asserted a false fact about a NAMED
entity that was present, numeric and updating - so the operator opened
Developer Tools, found it working, and had every reason to conclude the
diagnostics were broken.

THE FIX. Each message states what was OBSERVED and names BOTH explanations.
Three wordings, no shared helper: two are a log line about a behaviour, the
third is a banner about an entity, and a helper serving both would drop
whichever half the other caller does not need. SoC stays STATED, NOT WIRED -
part 11b drives the two worlds at 50 % and 11 % and the sentence does not move
one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()`
already rides `liveness_notes()` a few rows below, so the discriminator is "is
that row there?", and part 11d asserts it is present in one world and absent in
the other.

THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground
truth written down rather than derived from the published stream, asserts they
are really two (40 of 40 reads differ) while the published stream is
bit-identical, and only then grades the sentences. Eleven mutations, all red:
each of the three sentences reverted (3, 2 and 2 named reds), the two
explanations transposed at ZERO bytes (1), the second explanation deleted (1),
the admission of ambiguity replaced by a ranking (1), all three reverted at
once (5), and two aimed at this card's own check - collapsing the world pair
(2) and emptying the accusation list, which a positive control against the
three sentences as 0.10.1 shipped them catches (1).

AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE.
Transposing `label` and `entity` in the row made it unfindable and the run died
on an IndexError - a red with no name and every later check ungraded, FCST-06's
shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3
after. And the first invariance run compared a tree with itself, because it
archived a branch whose edits were not committed yet; the tool now REFUSES to
report "behaviour unchanged" unless the sentences halves differ, and both of
its refusals are driven.

SAFETY-57's part 10h check was the handoff and is inverted rather than deleted:
it required the accusation to still be present so that removing it had to be a
deliberate act. This is that act. Its escape clause is untouched.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 21:02:40 +02:00
admin 239e120bf6 SEC-2: write down the release path, now that master refuses a direct push (#108)
Reviewed on the PR (issuecomments 1480, 1485, 1488, 1489). Documentation only: docs/RELEASE.md (new, 116 lines) plus a 2-line pointer in README.md's file map. No code, no workflow change.

Verified against the forge rather than accepted from the document:
- Both branch_protections rules re-read field-for-field identical (enable_push false, enable_force_push false, status check `checks / unit (pull_request)`, required_approvals 0, block_admin_merge_override true, no whitelists, no file patterns); master's rule created 2026-09-23T03:09:21; default_branch still master. Section 2's After table is exact. No protection was changed by this review - both rules re-read with their original updated_at.
- The empty-PR 405 trap reproduced independently on my own control (throwaway PR #111 between two same-SHA unprotected branches): empty PR -> 405 "Please try again later"; same PR after one commit -> 409 "head out of date". Section 1's "read the message, not the code" is correct and both messages are quoted accurately. PR #111 closed, branches deleted.
- release/1.0 (b0b24e5) is an ancestor of master (0f14c2a), so a literal release PR is empty right now - PR #109 has 0 commits, 0 files, and Gitea opened it anyway. Section 1's closing note is right.
- A master-base PR gets a genuine CI run, not a laundered skip: PR #107 head cb7e45b3 shows Waiting -> In progress -> success, "Successful in 7m56s", run 814. This PR's own head 9d593429 likewise, run 818.
- All five required steps are present and correctly ordered: work merges to release/1.0 with a version bump in goodwe_controller/config.yaml; release via PR release/1.0 -> master; direct push refused; green check before merge; `ha store reload` after, without which the Supervisor keeps serving the cached version. required_approvals: 0 is stated twice, so no reader can believe the dev/review split is enforced.

One non-blocking defect left open (issuecomment 1485): section 1 calls 0f14c2a "the only commit on master that carries no (#NN) merge suffix". 274 of master's 297 commits carry no suffix. The true statement is stronger - no release has ever reached master through a pull request, and 6f5e8e3 (0.10.0) was promoted by the same direct push one release earlier, so 0.10.1 was the second, not a one-off. Background, not procedure; wants a follow-up line change.

A third comment of mine (issuecomment 1488, merge style) is withdrawn in issuecomment 1489 - it rested on a premise I had not checked and which is false.
2026-09-23 04:08:18 +02:00
glenn schrooyenandClaude Opus 5 9d593429d3 SEC-2: write down the release path, now that master refuses a direct push
checks / unit (pull_request) Successful in 7m56s
master is the default branch, which is what the Home Assistant Supervisor
clones - so whatever sits on it runs on a real 5 kW inverter. Until today it
had no branch protection at all, while every gate this repo built sat on
release/1.0, the branch that does not ship. 0.10.1 went straight onto master
on 2026-09-22 with no PR, no CI and no reviewer, and it succeeded.

master now carries the same rule as release/1.0 - enable_push false,
required context `checks / unit (pull_request)`, block_admin_merge_override,
required_approvals 0. That is a forge setting and cannot live in a commit, so
the part that can is this: the procedure it implies. Without it the next
release is attempted the old way and fails confusingly at the push.

docs/RELEASE.md records the path (merge to release/1.0, bump version, PR
release/1.0 -> master, wait for green, merge, then `ha store reload` before
the Supervisor offers anything), the before/after protection state of both
branches, and how the gate was proven to bite rather than merely badge green.

Documentation only - no code, no workflow change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-23 03:43:37 +02:00
admin b0b24e5d41 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0 (#105)
checks / unit (pull_request) Successful in 7m57s
Reviewed on the PR (issuecomments 1455-1462): identity verified unchanged (slug goodwe_controller, no image: key, installed slug 68285ac7_goodwe_controller corroborated from HA backup metadata); version-gate 11a-11d re-implemented and verified independently; the 16-of-93-at-790s pairing re-derived from the committed 16-day capture (>=710: 17, >=790: 16, >=815: 15); the SAFETY-57 entry verified as a faithful move carrying only the three declared #104 documentation fixes; corrected 0.10.0 preamble corroborated to the day against backup metadata; suite 30/30 reproduced with checks.yml's exact command. Two files, no control code. The 710 s/790 s split between test_freshness.py and this file is a known, stated gap and takes its own card.

NOTE: master (the Supervisor's default branch) is still 0.10.0 and still lacks SAFETY-57. This merge does not deliver; bringing master to release/1.0 is a fast-forward and is the remaining step.
2026-09-22 15:52:25 +02:00
glenn schrooyenandClaude Opus 5 eb88646899 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
checks / unit (pull_request) Successful in 8m0s
The Supervisor decides an update exists by comparing config.yaml's version
string with the one the box reports. master and release/1.0 both said 0.10.0,
so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on
2026-09-21 while the house exported 2.4 kW - had no way of reaching the box
that has it. A patch bump is the delivery mechanism, not bookkeeping.

- config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves:
  no key added or removed, no default changed, no enum widened, no rule added
  to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1.

- CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out
  of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever
  cut the next version owned moving it; this is that cut, so that paragraph is
  replaced by what an operator actually needs in order to decide - what the
  deadlock does (command parked at 0 W, batt reading gated to MISSING, escape
  condition unreachable from inside the state), how to recognise it (setpoint
  stuck at exactly 0 W with the battery register flat, a STALE line and no
  clearing line) and that a restart of the add-on clears it.

- PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been
  built or pulled anywhere" and that the reference install was on 0.7.0. Both
  stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a
  repository add-on (slug 68285ac7_goodwe_controller) and began regulating a
  real battery. The section's own bump argument was explicitly conditional on
  that; the condition has fired, so the argument is retold as history and the
  section is closed. This text is what the Supervisor shows beside Update.

Also fixes, in this file only, the three non-blocking documentation defects
raised on PR #104 - issuecomments 1445, 1446 and 1447:

- 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled:
  at >= 790 s the committed 16-day replay gives 16 of 93, not 17.
- 1447: the BMS keep-alive pulse was an inference stated as a measurement. No
  committed capture identifies one, and on this episode 0x0518 never moved at
  all. The measured claim replaces the firmware story.
- 1445: "neither consumer can reach a verdict inside one window" is universal
  as written and graded on a fixture that puts the obligation back on every
  release. It now carries the qualifier the reviewer asked for, and names the
  unbounded case and why the shipped one-inverter roster cannot reach it.

The same three defects also sit in app/freshness.py, test_freshness.py,
DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this
PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a
live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:22:03 +02:00
admin b9f7bd0506 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested (#104)
Reviewed and merged by the PR review agent. Acceptance criterion re-driven independently off the committed RS-485 capture: the verdict is withdrawn with 0x0518 held constant at -3 W, no external change. Counterfactual release_s=inf verified byte-equivalent to the shipped-before module over 242336 reads, so the SAFETY-37 conjunction is untouched. 30/30 suite green in checks.yml's own image; 11 mutations re-applied on a purged tree; the argued-equivalent M7 proved equivalent over full internal state.

Three non-blocking documentation findings filed on the PR (comments 3/7, 4/7, 5/7) for a follow-up before the next version is cut.
2026-09-21 15:49:35 +02:00
adminandClaude Opus 5 43c144b8d9 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-21 12:33:56 +02:00
adminandClaude Opus 5 6f5e8e378a PKG-15: bring release/1.0 to master at 0.10.0, so the default branch is the add-on
`master` has been `5184a2c` / v0.2.1 since 2026-08-25 - the tree that drives the
house - while every ticket since has landed on `release/1.0`. Three consequences
were live, and all three are what this merge closes:

 * `master` is the DEFAULT branch, so Gitea indexes dispatchable workflows from
   it. `master` had no `.gitea/` at all, which is why `GET /actions/workflows`
   answered empty and the UI's dispatch button was missing.
 * new pull requests default to `master`. #102 was opened against it by
   accident and its first run graded the change merged into a stale `master` -
   an honest-looking green answering the wrong question.
 * AND THE ONE THAT NOW MATTERS MOST: the repository has not yet been added to
   Home Assistant, and adding it is part of this release. `master` is the
   branch the Supervisor will read the moment it is added, so whatever stands
   here is what it shows and offers to install. This merge is what makes that
   0.10.0 rather than 0.2.1.

NO CONFLICT WAS POSSIBLE AND THAT WAS CHECKED, NOT ASSUMED. `master` is a
direct ancestor of `release/1.0` (`git merge-base --is-ancestor` true), the
merge base IS `5184a2c`, and `origin/release/1.0..origin/master` is empty -
`master` carries nothing `release/1.0` lacks. `--no-ff` is used anyway, so the
crossing from 0.2.1 to 0.10.0 is one addressable commit rather than a pointer
move.

WHAT CROSSES: config.yaml 0.2.1 -> 0.10.0. The add-on the Supervisor will build
is a different program from v0.2.1 in the ways `goodwe_controller/CHANGELOG.md`
sections 0.3.0 through 0.10.0 describe - among them P1 meter ingestion, the
separate integrator the velocity form did not have, shadow mode, the tariff
plan and the multi-inverter roster. The ROLLBACK POINT does not move: tag
`v0.2.1` still names `5184a2c` and still names the tree that ran the house.

NOT A DEPLOY. Nothing is installed, no `ha addons update` is run, `/addons/` is
untouched and neither 192.168.2.158 nor 192.168.2.221 was contacted. Adding the
repository to Home Assistant, and pressing Install, remain the owner's call.

Verified before merging: the full suite in the CI image on `release/1.0`'s tree
- `cp -a`, purged, `:ro` mount, one file at a time, exactly the command in
.gitea/workflows/checks.yml - 30/30, exit 0; and PR #103's own
`checks / unit (pull_request)` was a real `success` on the merge, not a skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-20 12:34:30 +02:00
admin 8a5736136a PKG-15: fold TEL-10 and SAFETY-48 into 0.10.0, so the version names its contents (#103)
Reviewed and merged by the PKG-15 review agent.

Verified rather than accepted:
- test_version_gate.py 11d pins `## Unreleased` blocks below the newest section at exactly one; deleting the block goes red (reproduced on a copy of the tree).
- 32 runtime commits since 86bdf49 and 24 accounted by the contents table, re-derived with predeploy_check.py's own RUNTIME rule; PKG-14's superseded 28/21 were correct when taken.
- app/validate.py imports neither main nor mqtt (read by AST), and SLEW_RAIL_FRAC / LOOP_TICK_S / PLANT_SETTLE_S are byte-identical across cb3f686..HEAD, so PKG-14's CLEARED transcript still holds.
- CI-9 (16b1d6c) touches only .gitea/workflows/checks.yml, outside the built image and outside RUNTIME.
- The SAFETY-48 entry keeps the capacity-tariff misreading out: no worst-quarter row in the A/B table, and the 3739 -> 3434 W figure appears only inside the paragraph that disclaims it.

Three non-blocking defects posted on the PR: the gain:0.3 / slew-rail tie values are swapped against DECISIONS/SAFETY-48; "touch three files between them" should say three runtime files; and the stated reason cb3f686 enters the count ("last..HEAD excluded it") is wrong, though the count is right.
2026-09-19 19:08:05 +02:00
adminandClaude Opus 5 5aba220046 PKG-15: fold TEL-10 and SAFETY-48 into 0.10.0, so the version names its contents
checks / unit (pull_request) Successful in 7m53s
0.10.0 was cut by PKG-14 on 2026-09-16 and three tickets have merged into
release/1.0 since. Two of them change the built image - one is a CONTROL LAW
change and one adds three MQTT entities - and neither is described anywhere in
this file. Tagging that tree today would stamp a number on contents the
changelog does not account for.

FOLDED RATHER THAN CUT AS 0.11.0, AND THE ARGUMENT IS CONDITIONAL.
0.10.0 has never been built or pulled anywhere: the reference install is on
0.7.0 (PKG-14's own predeploy run records `deployed on the box : 0.7.0`), the
repository carries exactly one tag, `v0.2.1`, and no release. So the string
`0.10.0` does not yet name a tree anyone is running, and the PKG-06/PKG-07
no-op that forbade re-cutting `0.9.0` does not reach it. Cutting 0.11.0 would
burn a number on a release that never left the repository. The condition is
checkable and the section now says so: run against a box already reporting
0.10.0, `tools/predeploy_check.py` refuses with THE DEPLOY WOULD BE A NO-OP and
names the runtime commits since the bump. `config.yaml` stays at 0.10.0, so
`test_version_gate.py` 11b still finds the two sides equal.

SAFETY-48 IS THE ENTRY THAT MATTERED MOST TO GET RIGHT, AND WHAT IT PROMISES
IS LESS EXPORT - NOT A LOWER BILL. The measured result is export 39.61 -> 34.07
kWh over the committed 16-day replay, 14 % of the replayed figure, falling at
every plant and tuning variant swept (~4 % to ~16 %, median near an eighth).
The section says in its own heading and its first line that the control law
changed, and it says PLAINLY that the billed quarter is not a claim: at every
plant lag <= 5.0 s - including the one that best reproduces the recorded export
- release/1.0's worst quarter is ALREADY 3434 W and this changes it by exactly
zero. The 3739 -> 3434 W headline exists at the single longest fitted lag, and
the lag is not identified by the recording. The costs are in the entry too: the
cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks, 679 of
them outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not
own the over-absorption" is now false.

TEL-10 gets the three site rows, what each one is, and the two things a reader
would otherwise get wrong: the lead is NOT a subtraction of two published rows,
and `Loop reason` is not the `Why` row. It repeats neither of the two sentences
TEL-10's own review retracted - EIGHT messages stay first and the ninth,
`goodwe_ctl/availability`, is byte-identical but deliberately moves to last.

CI-9 IS DELIBERATELY NOT IN THIS FILE, AND THE OMISSION IS WRITTEN DOWN RATHER
THAN SILENT. It changes one file, `.gitea/workflows/checks.yml`, outside
`goodwe_controller/` entirely: not in the built image, not in `is_runtime()`'s
list, and not observable on any version of the add-on. This file is the release
note the Supervisor shows beside the version number, and a row here has to be
something the person deciding whether to press Update could act on. The note
above SAFETY-48 says so and says the rule is about audience, not importance.

THE STRAY `## Unreleased` AT LINE 1777 IS NOT TOUCHED, AND THAT IS A DECISION.
It is the 0.8.0-era block PKG-14 annotated: every row in it has shipped, the
annotation says which release each went to, and re-filing it is PKG-08's card.
It is also load-bearing - `test_version_gate.py` 11d pins the count of
`## Unreleased` blocks below the newest section at EXACTLY one, so deleting it
is red on the commit that does it, and so is adding a second.

TWO MEASURED FIGURES IN PKG-14'S PROSE WENT STALE AND ARE RE-DERIVED, NOT
ADJUSTED. The runtime-commit count since `86bdf49` is now 32, not 28, of which
the contents table accounts for 24, not 21; both re-run through
`predeploy_check.is_runtime()` itself. The other eight are the three misfiled
tickets (SAFETY-43 x2, SAFETY-44 x3, TEST-21 x2) and `cb3f686`, PKG-14's own
bump, which edits `config.yaml` and so counts as runtime - it was HEAD when
PKG-14 measured and is inside the range now.

PKG-14's `CLEARED, exit 0` predeploy transcript was NOT re-run and now carries
the check that says why it still holds: `config.yaml` is untouched by both
tickets; `app/validate.py` imports neither `main` nor `mqtt`, and the three
names it takes from `control` - SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S -
are byte-identical across cb3f686..HEAD.

Verified: the full suite in the CI image, `cp -a` of the tree, purged, `:ro`
mount, one file at a time, exactly the command in .gitea/workflows/checks.yml -
30/30, exit 0. No runtime file is in this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 18:49:09 +02:00
186 changed files with 39628 additions and 3103 deletions
+2
View File
@@ -0,0 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
+33 -13
View File
@@ -286,8 +286,9 @@ jobs:
run: |
set -eu
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
git init -q .
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
git init -q . && git remote add origin "$URL"
# Token as an env-set header: not in argv or .git/config (TOOL-9).
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
if [ -n "$PRNUM" ]; then
# Two fetches, not one, so that a failure to reach either ref fails
# HERE with the ref in the message, rather than inside the merge
@@ -488,6 +489,31 @@ jobs:
fi
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
# from the merged tree), as the sibling test_freeze.py looks for. Any
# failure here fails the job; the tests step below refuses the stub.
- name: check out admin/goodwe at the pin in .gitea/infra-pin
env:
URL: ${{ github.server_url }}/admin/goodwe.git
# Read access to admin/goodwe only; this job's own token cannot see it.
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
git init -q .
# The token goes in a header via env: not in argv, and not in .git/config,
# which is mounted into the tests' container and outlives a failed job.
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PIN"
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
echo "infra: admin/goodwe @ $(git log --oneline -1)"
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
run: |
set -eu
@@ -508,16 +534,9 @@ jobs:
# shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts.
#
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
# otherwise over-claim. test_freeze.py's third section wants the infra
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
# transport. admin/goodwe is PRIVATE (checked via the API) and this
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
# absent here and `find_hwsim()` returns None. The rest of the file runs
# and passes. It does so SILENTLY - the file prints nothing to say the
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
# claim than it looks. That belongs to whoever owns test_freeze.py next;
# naming it here is the least this gate can do about it.
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
# checkout below is bind mounted `:ro`, so the interpreter physically
@@ -533,7 +552,7 @@ jobs:
- name: the add-on's own tests
run: |
set -eu
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
cd /w/goodwe-addon/goodwe_controller
set -- test_*.py
if [ "$1" = "test_*.py" ]; then
@@ -546,6 +565,7 @@ jobs:
start=$(date +%s)
if python3 "$f" > /tmp/out 2>&1; then
echo "PASS $f ($(( $(date +%s) - start ))s)"
grep "^CI: " /tmp/out || true
else
echo "FAIL $f ($(( $(date +%s) - start ))s)"
tail -40 /tmp/out
+46
View File
@@ -0,0 +1,46 @@
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
# covers a move of release/1.0 there. Deliberately NOT a required check: it
# reports; `checks / unit` stays the only gate. How to bump: README.md,
# "CI and the infra pin".
name: infra-pin
on:
pull_request:
push:
branches: [release/1.0]
jobs:
fresh:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
steps:
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
env:
SELF: ${{ github.server_url }}/${{ github.repository }}.git
INFRA: ${{ github.server_url }}/admin/goodwe.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
git init -q .
# The pin of the commit this event is about (the PR head, or the push).
# Token as an env-set header: not in argv or .git/config (TOOL-9).
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
# `|| true`: under the runner's pipefail a missing or comment-only pin
# would stop here on git's error instead of the FAIL line below (TOOL-9).
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
# Header via env, as in checks.yml: the token stays out of argv.
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
if [ "$PIN" != "$HEAD" ]; then
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
exit 1
fi
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
+6 -1
View File
@@ -4,6 +4,10 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
@@ -18,7 +22,8 @@ copy cannot drift from the PDF someone is holding in a cellar.
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
```
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
script is a Windows path, so edit it on any other machine. Screenshots live
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
+14 -3
View File
@@ -30,9 +30,11 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
protected, releases go via PR from `release/1.0`
```
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
@@ -48,6 +50,9 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
then install **GoodWe RS485 Controller**.
Then turn on **Watchdog** on the add-on's Info tab — see
[Before you start](goodwe_controller/DOCS.md#before-you-start).
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
anonymously; against a private repo the store shows nothing and gives no useful
error. If it must stay private, install locally instead: copy
@@ -77,8 +82,8 @@ machine itself dying:
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
Sites sold without the e-stop must have the client acknowledgement in the field
guide (Step 7b) signed.
## Development
@@ -100,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
## Compatibility
**Check the serial number, not the model name.** Characters 6-8 of the serial are
+64
View File
@@ -0,0 +1,64 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **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.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
- **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 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
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
## 3. Severity
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
| Level | Colour | When |
|---|---|---|
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
| info | grey | No action needed. 1 sentence. |
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
A rewrite never changes an alarm's severity, `notification_id` or cadence.
## 4. Words - the same word for the same thing everywhere
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
| Say | Not |
|---|---|
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
| battery | pack, BMS pack |
| inverter, or its name | unit, device, actuator |
| meter | P1 feed, telegram |
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
| 15-minute period | quarter |
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
| “Where the meter reading comes from” | meter source |
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
| draw the battery down, give power | discharge, discharging |
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
| safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| power request, what the add-on asks | command, setpoint (on the page) |
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
| check for inverters working against each other | circulating power check |
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
+116
View File
@@ -0,0 +1,116 @@
# Releasing — how a change reaches the battery
`master` is the **default branch**, and the default branch is what the Home
Assistant Supervisor clones when it installs or updates this add-on. Whatever
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
as a deployment, because that is what it is.
---
## 1. `master` is protected — releases go through a pull request
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
**The release path is now:**
1. Merge the work into `release/1.0` as usual, via a pull request that goes
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
the Supervisor keys the built image by `version:`, so without a bump nothing
is offered and nothing changes.
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
it will be refused by the pre-receive hook:
```
remote: error: Not allowed to push to protected branch master
! [remote rejected] ... (pre-receive hook declined)
```
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
filter**, so a `master` base matches and a real run happens — verified on
throwaway PR #107 (base `master`), which queued a genuine run.
⚠️ Green means green. A `skipped` status is laundered into a `success`
rollup by this Gitea and the merge gate accepts it — see the long note at
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
4. Merge the pull request. `required_approvals` is **0**, so a green check is
the whole gate; there is no second pair of eyes unless someone asks for one.
5. **Reload the store.** Merging to `master` does not, by itself, offer the
update. The Supervisor only sees the new version after:
```bash
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
```
Until `ha store reload` runs, the add-on page still shows the old version
and the release looks like it did nothing.
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
so a `release/1.0 → master` pull request is empty until new commits land on
`release/1.0`. That is expected; it means there is nothing to release yet.
---
## 2. Branch protection — before and after SEC-2
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
**Before (2026-09-23):**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | **no rule at all** | — | — | — |
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
**After:**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
Both rules are otherwise identical: `enable_push: false`,
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
no file patterns. `required_approvals` stays at **0** on purpose — raising it
would block the review agent from merging, and nobody asked for it.
The default branch was **not** changed. Changing it would change what the
Supervisor clones, which is a different decision from this one.
### How the gate was proven to actually bite
A protection rule on this forge can *appear* satisfied while having checked
nothing, so the rule was tested rather than trusted. The discriminator is a
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
branch protection *before* it checks the head, so
* `409 head out of date` → the request **cleared** protection;
* `405 Not all required status checks successful` → protection **refused** it.
Read the *message*, not just the code: `405` is also returned as
`Please try again later` for a pull request with **no commits**, which has
nothing to do with protection. An empty pull request on an unprotected base
gives that same `405` (verified, throwaway PR #110), so an empty
`release/1.0 → master` reports it too.
Validated against a control on an unprotected base (PR #106), which answered
`409` while its check was still pending — i.e. protection was no obstacle
there. On `master` (PR #107) the same probe answered `405` while pending, and
`409` once the check was genuinely green. A direct `git push` to `master` was
rejected by the pre-receive hook with `master`'s SHA unmoved.
---
## 3. Rolling back
There is no force-push escape hatch: `enable_force_push` is `false` on both
branches. To undo a bad release, land a revert on `release/1.0`, bump the
version again, and take it through §1. The `/addons/` rollback for the
reference install is in `HANDOVER.md`.
+2 -2
View File
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+2 -2
View File
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<small>Installer copy</small>
</div>
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+9
View File
@@ -0,0 +1,9 @@
# UX-13 screenshots
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
edit them by hand: run the script again.
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
- `setup-step-1*.png`: Setup, step 1.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+859 -3
View File
@@ -1,5 +1,623 @@
# Changelog
## 0.12.0
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
more than one PV inverter.** The option surface moves **76 keys -> 77**,
`options:` and `schema:` in step. 0.11.0's `pv_entity` takes one entity; a roof
with two or three inverters, each publishing its own power sensor into Home
Assistant, could only record one of them. `pv_entities` is a list, and the
Configuration tab renders it as a repeatable field, so you add your second and
third inverter there rather than in YAML.
⚠️ **UNSET, NOTHING CHANGES.** `pv_entities` ships as `[]`, which is what every
install that presses *Update* lands on, and empty means "use `pv_entity`" - so
an install with no PV entity still has no PV channel, and an install with one
`pv_entity` keeps exactly the single-entity channel 0.11.0 gave it. **No rule was
added to `validate.py`**, which mentions neither key, so **a stored options blob
that starts the add-on on 0.11.0 starts it on 0.12.0**. There is no
`SystemExit(78)` to plan around on this hop.
⚠️ **IT IS INSTRUMENTATION ONLY, NOT A CONTROL INPUT.** `app/control.py`,
`allocator.py`, `arbiter.py`, `peakguard.py` and the watchdogs are not in this
release's diff; the runtime files that move are `app/pv.py`, `app/main.py`,
`app/mqtt.py`, `config.yaml` and `translations/`. `tools/tel11_pv_trace.py`,
extended for this card, hashes the controller's decisions on 0.11.0 and on this
tree: the control document is **`846a10a5b89e…` with PV unset and with one
entity, on both trees** - the same hash TEL-11 recorded - and with a
three-entity roster it equals each grid world's unset document, including every
cell where one inverter is dead. Neither the total nor any partial total reaches
a decision. With PV unset or with one entity, the **telemetry** is byte-identical
to 0.11.0's as well.
Contents of this release, in merge order since 0.11.0 (`cccb2fa`):
| | |
|---|---|
| `f863eca` | TEL-12 - several PV inverters, and one absent member is an absent TOTAL **⚠️ NEW OPTIONAL OPTION, EMPTY ON EVERY EXISTING INSTALL** |
### TEL-12 — `pv_entities`: the PV channel for a roof with several inverters
**WHAT TO DO ON A SITE WITH MORE THAN ONE PV INVERTER.** List every inverter's
power sensor under `pv_entities`:
```yaml
pv_entities:
- sensor.pv_east_power
- sensor.pv_west_power
- sensor.pv_garage_power
```
`sensor.goodwe_pv_power` then carries the **site total**. Each entity's own
`unit_of_measurement` is read, so a kW inverter beside a W one sums correctly;
a sensor with no unit or a non-power unit is refused by name on the status
page, as in 0.11.0.
⚠️ **`pv_entities` REPLACES `pv_entity` - IT DOES NOT ADD TO IT.** When the list
is set, `pv_entity` is not read at all. **If you are upgrading from 0.11.0 with
`pv_entity` already set, the inverter it names must also be in the list**, or it
drops out of the total. Put it in the list and it is **counted once**, not
twice - that is why the rule is replacement: under a union, the natural action
(put all your inverters in the new field) would double-count the one the old
key already names. While both keys are set the status page shows a caution
saying which one won - *"`pv_entities` is set, so `pv_entity` is IGNORED - the N
entities in the list are what is summed. Clear `pv_entity` to remove this
caution"*. It is a caution, never a refusal to start and never a NOT READY row.
A repeated id inside the list is also counted once.
⚠️ **IF ANY CONFIGURED PV ENTITY IS ABSENT, THE PUBLISHED TOTAL IS `unknown` -
NOT THE SUM OF THE ONES THAT ANSWERED.** Three inverters configured and one
`unavailable`: `sensor.goodwe_pv_power` goes to `unknown` and the recorder
stores a **gap**. The sum of the other two would be a smaller number
indistinguishable from a real one, and a week later it would read as *"there
was less sun than there was"* - the conclusion this channel exists to test
rather than to manufacture. **A gap in the history, never an understated
number.** The status page, read live, still gives you the diagnostic: `PV —`
plus a caution that says there is **no site total**, names the missing entity
and its reason, and lists what the others reported. An inverter honestly
reporting **0 W** is summed, not treated as absent - shading and nightfall are
real zeros.
| what happened | `sensor.goodwe_pv_power` | the status page |
|---|---|---|
| neither key set | the entity does not exist | no row |
| all three answer, 1200 W + 2.1 kW + 450 W | `3750.0` | `PV 3 750 W` |
| all three answer, one of them `0 W` | the sum of the two others | the sum |
| one (or more) `unavailable` / missing / bad unit | `unknown` - a recorder gap | `PV —` + a caution naming what is missing |
⚠️ **THE FIX FOR A MISSING INVERTER IS NOT A TEMPLATE SENSOR THAT SUMS THEM.**
You can still point `pv_entity` at one if you prefer, but a template sensor
summing an `unavailable` source yields the partial total silently - the same
defect one layer down, where this add-on cannot see it or report it.
**WHY A SECOND KEY, RATHER THAN MAKING `pv_entity` A LIST.** Because an 0.11.0
install that has `pv_entity` set would not start. Measured against Supervisor
**2026.08.0** by `tools/supervisor_shape_probe.py` §G: with `pv_entity` declared
as a list, the string an 0.11.0 install has persisted is refused -
`Invalid list for option 'pv_entity'` - and a shipped `[]` default does not
rescue it, because the Supervisor's merge keeps your stored string. The one
single-key spelling that accepts both shapes, `match(.*)?`, validates nothing
(it accepts the integer `417`) and renders as a plain text box, so you could not
add a second inverter in the UI. So `pv_entity` stays a string, and the same
probe shows an 0.11.0 options blob - `pv_entity` set, no `pv_entities` key at
all - validating against this version untouched. ⚠️ **Rolling back to 0.11.0 is
also safe**: a schema without `pv_entities` drops the key with a warning and
leaves `pv_entity` standing.
**Why it matters now:** [[OPT-10]] needs a week of PV history to attribute the
surplus that left the reference site while the pack had room, and that site has
several PV inverters. The week starts when the site total is recorded.
**Full account: `DECISIONS/TEL-12-several-pv-inverters.md`, with the operator's
version under "Several PV inverters" in `DOCS.md`.**
### If you are crossing from a version older than 0.10.0
⚠️ **THIS SECTION IS NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.**
Updating installs 0.12.0 in one step, so every validator rule added in
0.8.0, 0.9.0 and 0.10.0 arrives at the same moment. Two of them can refuse a
config nobody has edited: [[SAFETY-39]]'s **rule 10** (added in 0.8.0) against
0.7.0's shipped default `slew_w: 1000` - the ceiling is **555.6 W** on a 5000 W
rail - and [[TARIFF-07]]'s three withdrawn `price_provider` ids,
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`**, each still
refused by name at startup on 0.12.0. Neither is new here, and a site already on
0.10.0 or later has already met both. **The full account of both, including
what to set instead of each withdrawn id, is in `## 0.10.0`**, which withdrew
them and is not superseded by this section.
`tools/predeploy_check.py` checks your live options against this version's
validator before you press *Update*, and on this tree reports a real version
change from 0.11.0. ⚠️ **Pass it the bare options dict - the `data.options`
object - NOT the whole `ha apps info --raw-json` document.** Handed the wrapped
export it currently prints CLEARED while having checked nothing ([[SAFETY-60]],
open). Extract the options first, e.g. `jq '.data.options'`.
## 0.11.0
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
`pv_entity` ([[TEL-11]]).** The option surface moves - **75 keys -> 76**,
`options:` and `schema:` in step - which is the same test 0.8.0, 0.9.0 and
0.10.0 were each cut on. ⚠️ **AND UNLIKE 0.10.0, NOTHING HERE CAN REFUSE A
STORED OPTIONS BLOB.** No key was removed, no default moved, no enum widened,
and **no rule was added to `validate.py`** - the validator does not mention
`pv_entity` at all, because nothing else reads it and it cannot conflict with
anything. The new key is `str?` - **note the trailing `?`, it is OPTIONAL** -
with an `options:` default of `""`, so a blob written on 0.10.0 or 0.10.1 does
not carry it, the Supervisor's merge supplies the empty string, and the empty
string is *off*. ⚠️ **A stored options blob that starts the add-on on 0.10.1
starts it on 0.11.0. There is no `SystemExit(78)` to plan around on this hop**,
and a site that presses *Update* and changes nothing else gets an add-on that
behaves exactly as its 0.10.1 did.
⚠️ **WHAT YOU ARE ACTUALLY DECIDING, IN ONE LINE EACH. TWO TICKETS HERE ARE
OBSERVABLE ON AN INSTALLED ADD-ON, AND NEITHER CHANGES A WATT.**
* [[SAFETY-56]] - **three messages stop naming your hardware as the fault** in a
state where the add-on cannot tell a dead register from a battery that is
simply empty at its BMS floor. **Text only; the behaviour is measured
byte-identical.** If you have ever opened Developer Tools because the add-on
told you a sensor had failed, and found the sensor working, this is that
defect. On the reference install the state it misdiagnoses arrives on most
nights and on every cloudy morning after a deep discharge.
* [[TEL-11]] - **one new optional key, `pv_entity`, and nothing whatever happens
until you fill it in.** Set it and the add-on records what your solar is
producing beside what the meter and the battery are doing. ⚠️ **It is not a
control input and it does not change what the loop does.**
⚠️ **THERE IS NO CONTROL-LAW CHANGE IN THIS RELEASE, WHICH IS WORTH SAYING
BECAUSE 0.10.0 HAD ONE AND NOTHING IN ITS PREAMBLE COULD WARN YOU.**
`app/control.py` is not in this release's diff at all, and neither are
`allocator.py`, `arbiter.py`, `arbitrage.py`, `clamp.py`, `peakguard.py`,
`maintenance.py` or `watchdog.py`. The runtime files that move are
`app/freshness.py` and `app/main.py` (SAFETY-56's three sentences), and
`app/pv.py` - new - with `app/main.py`, `app/mqtt.py`, `config.yaml` and
`translations/` (TEL-11). Contrast [[SAFETY-48]] in `## 0.10.0`, which altered
what the inverter is asked to do while touching no option, no default and no
validator rule, so that nothing a config check could see would mention it. This
release has nothing of that shape in it.
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
STILL NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons
update` does not walk the versions; it installs 0.11.0 in one step, so every
validator rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as
this section's nothing. Two of them can refuse a config that nobody has edited:
> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, against 0.7.0's own shipped
> default `slew_w: 1000` - the ceiling is 2/18 of the site rail, which is
> **555.6 W** on a 5000 W rail.
>
> [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
> `totalenergies_dynamic` and `eneco_eddy`** - each of which is **still refused
> by name at startup on 0.11.0**, with the same message and for the same
> measured reasons: a 404 from a gateway with no route, a TLS handshake against
> a hostname the certificate does not cover, and a 404 with an empty body.
⚠️ **NEITHER OF THOSE IS NEW IN 0.11.0, AND NEITHER IS NEWS TO A SITE ALREADY ON
0.10.0 OR 0.10.1 - IT HAS ALREADY MET THEM.** They are restated here only
because this is now the newest section, and a reader who opens the newest section
and stops would otherwise never meet the one class of thing that can stop the
add-on starting. **The full account of both is in `## 0.10.0` below**: what to
set instead of each withdrawn id, which tariff a site left on one of them has
really been running, why the refusal *is* the migration, and why all three ids
stay in `config.yaml`'s `price_provider` enum even though `PROVIDERS` is down to
three. **That is the section that withdrew them, it is where the evidence is,
and it is not superseded by this one.** ⚠️ The reference install is on
`price_provider: off` and at `slew_w: 400`, so neither applies to it.
`tools/predeploy_check.py` checks both against your live options blob before you
press *Update*, and on this tree it reports a real version change rather than
0.10.1's no-op.
Contents of this release, in merge order since 0.10.1 (`b0b24e5`):
| | |
|---|---|
| `c9aaf7f` | SAFETY-56 - the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can **⚠️ TEXT ONLY, BEHAVIOUR BYTE-IDENTICAL** |
| `94da37f` | TEL-11 - PV production stops being invisible, and stays out of the control law **⚠️ NEW OPTIONAL OPTION, UNSET ON EVERY EXISTING INSTALL** |
⚠️ **TWO MORE MERGES LANDED IN THIS WINDOW AND ARE DELIBERATELY NOT IN THE TABLE
ABOVE: [[CI-9]]'S FOLLOW-UP (`9f6b9f1`) AND [[SEC-2]] (`239e120`). THE RULE IS
[[PKG-15]]'S - AUDIENCE, NOT IMPORTANCE - AND BOTH FAIL IT THE SAME WAY.** This
file is the **add-on's** release note: the text the Supervisor renders beside the
version number, read by somebody deciding whether to press *Update* on a battery
that is currently regulating. A row here has to be something that reader can act
on.
* **CI-9's follow-up changes one file, `.gitea/workflows/checks.yml`**, exactly
as CI-9 itself did, and it is comment-only within that file. It is not under
`goodwe_controller/`, it is not in `predeploy_check.py`'s `RUNTIME` tuple, it
is not in the built image, and **there is no version of this add-on on which an
operator can observe it.** `## 0.10.0` excluded CI-9 on exactly that reasoning;
excluding the follow-up on any other reasoning would make the rule mean
nothing.
* **SEC-2 adds `docs/RELEASE.md` and two lines to `README.md`, and this one is
the closer call, so here is the whole of it.** SEC-2 is by some distance the
more consequential of the two: it is the ticket that made `master` refuse a
direct push, and it is *why this release reaches you as a pull request into
`release/1.0` and then into `master`, rather than as a push straight to the
branch the Supervisor clones* - which is how 0.10.1 got there. It is also
**purely a procedure for whoever cuts a release**; both files sit outside
`goodwe_controller/`, neither is in the image, and no install can tell whether
that document exists or what it says. An operator reading it learns nothing
about 0.11.0 that helps them decide whether to take it. So it is excluded, and
it is excluded under the same sentence as CI-9: ⚠️ **importance to this
repository is not the test. Observability on an installed add-on is.**
⚠️ **AND BOTH ARE WRITTEN DOWN, JUST NOT HERE.** CI-9's follow-up is PR #102 plus
the comment block at the top of `.gitea/workflows/checks.yml`, which carries the
measurements in full; SEC-2 is PR #108 plus `docs/RELEASE.md` itself, which is
now the canonical account of the release path and is the procedure this release
followed. ⚠️ **If a later CI or packaging ticket changes what the shipped image
contains, it belongs in this table.** Neither of these does.
### SAFETY-56 — three messages stop blaming healthy hardware
⚠️ **NOTHING THE ADD-ON DOES CHANGES. THREE THINGS IT SAYS DO.** Same
conjunction, same limits, same `MISSING`, same 0 W command, same NOT READY row,
same [[SAFETY-57]] release at 32 s. `tools/safety56_behaviour_trace.py` is what
says so rather than a commit message: it hashes a document with no sentence
anywhere in it, built from **2390 one-second reads replayed out of the committed
2026-09-21 bus capture**, a **66-cell command × register grid stepped 120 times
each**, and **six driven controller worlds**. That document is
`6030175ed2ab9a41…` on `release/1.0` and on this tree, byte for byte, while the
sentences differ. ⚠️ **And the tool REFUSES to report "behaviour unchanged" when
the sentence halves agree**, so an identical pair is a result rather than one
tree compared with itself.
**THE DEFECT, AND IT IS NOT A TYPO.** [[SAFETY-37]]'s freshness gate fires when a
battery-power register holds one value while the standing command moves. That
conjunction is satisfied by **two disjoint worlds**:
1. the register has stopped reporting reality - which is what the gate was built
to catch; or
2. **reality is not moving**, because the source cannot comply with the command.
A BMS refusing a discharge at its floor holds the pack still under any
setpoint, and a register reporting that honestly publishes the same unchanged
number a dead one does.
**Nothing the gate is handed separates them, and three sentences asserted the
first.** ⚠️ **On the one episode this repository has measured, it was the
second.** On 2026-09-21 the pack read **11 %** for the whole 850 s hold, **every
command inside that hold was a discharge**, and the register **answered the first
CHARGE command three seconds later**. All three numbers are read off the
committed capture by `test_freshness.py` part 11a rather than typed there. The
register was healthy and truthful throughout, and the add-on said it had failed.
**THE THREE SURFACES, so you can recognise what moved:**
| where you read it | what 0.10.1 said | what 0.11.0 says |
|---|---|---|
| the **action log** and the **status page** — both quote `freshness.py`'s `STALE_READING:` line verbatim, through `_read_unit()` and `stale_note()` | `… the reading is not following, so it is reported as MISSING` | `… TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART: the register may have stopped reporting reality, or the source may be unable to comply with the command …` |
| the **add-on log**, at ERROR, from `main.py` | `SAFETY-37: <unit> battery-power register is not following its own command` | `SAFETY-37: <unit> battery-power reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER` |
| the **readiness row** on the diagnostics page | `battery power: <entity> is missing or not numeric` | `battery power: no usable value this cycle from <entity> - and this row cannot tell which of two things that is … Confirm the entity in Developer Tools before replacing it` |
⚠️ **THE THIRD ROW IS THE ONE THAT COST AN OPERATOR AN EVENING, AND IT IS THE
WORST THING A DIAGNOSTIC CAN DO: IT ASSERTED A FALSE FACT ABOUT A NAMED
ENTITY.** It read *"is missing or not numeric"* for **any** `None`, and one of
the ways that value becomes `None` is the freshness gate withholding a reading
that is present, numeric and updating - `Freshness.reading()` returns `None`
there **by design**. So the operator opened Developer Tools on the entity the row
named, found it working, and had every reason to conclude the diagnostics were
broken. **It is fixed by saying less, not by looking harder:** the row does not
branch on the gate's state, because the gate already has a surface of its own -
`stale_note()` rides `liveness_notes()` a few rows below with the whole
`STALE_READING` sentence in it - so your discriminator is *"is that row there?"*,
and this row's only job is to stop pre-empting the answer.
⚠️ **WHAT IT DOES NOT GIVE YOU IS WHICH OF THE TWO IT WAS.** Naming which would
need the SoC and the pack floor, and those are deliberately not `freshness.py`'s
inputs - they are [[TEL-08]]'s and [[SAFETY-32]]'s numbers, on their own rows.
**SoC is STATED in these sentences and never WIRED into them**: part 11b drives
the same two worlds at 50 % and at 11 % and the sentence does not move one byte.
What changed is that the add-on now tells you it cannot tell, instead of guessing
and blaming your hardware. **The reading rule is unchanged from
`### SAFETY-57` below**: if the stale/withdrawn pair keeps repeating, the unit
really does want looking at; if it happens once at a low SoC and stops, the pack
was empty and the site has resumed.
⚠️ **AND THE THREE WORDINGS SHARE NO HELPER, ON PURPOSE.** Two of them are log
lines about a **behaviour** and the third is a banner about an **entity**; they
answer different questions ("what did the register do" against "can I trust this
entity"), and one helper serving both would have to drop whichever half the other
caller does not need - and the half it dropped would be the operator's next step.
**Full account: `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`.**
### TEL-11 — `pv_entity`: PV production gets recorded, and stays out of the control law
⚠️ **UNSET, THIS IS NOT A CHANGE AT ALL - AND THAT IS MEASURED RATHER THAN
ASSERTED.** `pv_entity` ships empty, which is what **every** install that
upgrades into this version lands on. Empty means the add-on **reads no entity,
announces no entity, publishes no topic, shows no status row and adds no page
row.** `tools/tel11_pv_trace.py` drives five grid behaviours × eight
PV-sensor behaviours through 24 cycles of the **shipped** `Controller`, on
`release/1.0` and on this tree, and hashes two separate documents - one of
**control** decisions and one of **telemetry**:
| document | tree | `pv_entity` | sha256 |
|---|---|---|---|
| control | `release/1.0` | unset | `846a10a5b89e3210…` |
| control | TEL-11 | unset | `846a10a5b89e3210…` |
| control | TEL-11 | **set** | `846a10a5b89e3210…` |
| telemetry | `release/1.0` | unset | `a443a6ceaffe072b…` |
| telemetry | TEL-11 | unset | `a443a6ceaffe072b…` |
| telemetry | TEL-11 | **set** | `4e6d1cb289a95722…` |
**Read the two `unset` rows first.** They are byte-identical in **both**
documents, which is a stronger claim than "the control path is untouched": an
install that does not set the option sees no change of any kind, announcements
and status rows included. **Then read the `set` row of the control document** -
identical again, while telemetry moves. ⚠️ The tool **REFUSES** unless the two
trees' `app/` fingerprints differ and unless exactly one side reacts to the
option, so a matching pair cannot be two runs of the same tree.
⚠️ **IT IS NOT A CONTROL INPUT, AND IT DOES NOT CHANGE WHAT THE LOOP DOES.** It
is instrumentation and nothing else. It is not read by the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog; it
is **not part of `inputs_ok`**, so a PV integration that breaks cannot stop your
battery being regulated; and its status row is a **caution** (`warn`), never a
`bad` row, so a telemetry channel can never put a battery site into NOT READY.
`test_pv.py` part 6 drives 40 cycles of the real `cycle()` and asserts the
commands are bit-identical across unset / no reading / 0 W / 8 kW, with a
**computed** census of all 33 modules under `app/` showing the material is
mentioned in exactly three of them: `pv.py`, `main.py`, `mqtt.py`. Wiring
production into the control law would be a behaviour change on a system driving a
real battery - and it would destroy the measurement, because the loop would then
be reacting to the thing being measured.
**WHAT YOU GET IF YOU DO SET IT.** One new MQTT-discovery sensor,
`sensor.goodwe_pv_power`, `device_class: power` and ⚠️ **`state_class:
measurement`** - which is the point of the row, because that is what puts the
number into the recorder's long-term statistics, and a week of statistics is what
the attribution needs. **Positive is producing.** The unit is **read off your
entity's own `unit_of_measurement`** - `mW`, `W`, `kW`, `MW`, `GW`, `TW`, Home
Assistant's own power units, every factor an exact power of ten. A sensor with
**no** unit, or with a unit that is not power, is **refused by name, and the
status page says which entity and which unit.** There is deliberately no
magnitude heuristic: nothing can separate 30 W of dusk from 30 kW of noon, and a
silent factor of 1000 in the one channel that exists to be believed a week later
is worse than a refusal you can read.
⚠️ **ABSENCE IS PUBLISHED AS `unknown`, NOT AS 0 AND NOT SKIPPED. THIS IS THE
DESIGN DECISION WORTH YOUR ATTENTION.**
| what happened | `sensor.goodwe_pv_power` | the status page |
|---|---|---|
| `pv_entity` not set | the entity does not exist | no row at all |
| set, sensor answered 4100 W | `4100.0` | `PV 4 100 W` |
| set, sensor answered 0 W | `0.0` | `PV 0 W` |
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
The last two rows are why the key is shaped this way. `publish()` normally
**drops** a `None`, which is right for `circulation` - there the last figure
standing beats a zero that reads as health - and **exactly wrong here**: a
dropped row leaves Home Assistant holding the last number it was told, for ever,
and the recorder then draws a **flat line through the outage**, which is
indistinguishable from a measurement. Anyone reading that week back would
conclude the sun was not shining, ⚠️ **which is precisely the conclusion this
channel exists to TEST rather than to manufacture.** So the absence is published,
using Home Assistant's own no-value payload (`mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it *before* the numeric
branch), the entity's state becomes **`unknown`**, and the recorder stores a
**gap**. A failed read also **clears** the previous good one: the value never
outlives its own sensor.
⚠️ **WHY IT IS A HOME ASSISTANT ENTITY AND NOT A REGISTER.** The RS485 bus this
add-on speaks carries the **battery** inverter it drives. On nearly every site the
PV inverter is a different box from a different vendor on its own connection,
reporting into Home Assistant through its own integration - so this takes
whatever `sensor.*` your PV integration already provides, **any brand Home
Assistant can read**, and nothing anywhere assumes it is on the bus.
**THERE IS NO `pv_invert`, AND THAT IS NOT AN OVERSIGHT.** `meter_invert` and
`batt_invert` exist because those two quantities are **bipolar and are read by
the control path**, where a reversed sensor hands the site a plausible number it
acts on. Production is unipolar and is read by **nothing**, so a reversed sensor
cannot mislead a decision, and it announces itself as **−4 kW at noon** in the
very history this key exists to create. A flag would only let an installer hide
it. There is no negative-value alarm either: a PV inverter's own standby draw
makes a small negative reading at night ordinary, so any threshold would be a
number nobody has measured.
⚠️ **WHAT THIS RELEASE DOES NOT DO, AND IT IS THE REASON THE CARD EXISTS.** It
does not answer [[OPT-10]]'s question. That card measured this site over sixteen
days: the pack reached 90 % SoC on **two** of them, and **20.5 kWh of surplus
left the site while the pack still had room** - 12.6 kWh of it with the battery
not charging at all - and it could not say **why**, because PV production was
recorded **nowhere**. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which is
the only software defect), and guessing between them is how an optimiser gets
tuned to solve a cloud problem. **0.11.0 adds the channel. The attribution needs
a week of history through it, and is OPT-10's own card.** So the reason to set
`pv_entity` on the day you take this update is that the clock on that week starts
when you do.
**Full account: `DECISIONS/TEL-11-pv-production.md`, with the operator's version
under "PV production" in `DOCS.md`.**
## 0.10.1
**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last
place moves because this is a bug fix to a version that has shipped and is
regulating a battery, not a change to what the add-on offers. **The option
surface does not move at all** - no key added, none removed, no default changed,
no enum widened, no rule added to `validate.py` - so ⚠️ **a stored options blob
that starts the add-on on 0.10.0 starts it on 0.10.1**, and there is no
`SystemExit(78)` to plan around on this hop. If you are on 0.10.0, the only
question worth your time is whether you have been hit by what it fixes, and
`### SAFETY-57` below is written to answer exactly that.
⚠️ **WHY THIS IS A NEW NUMBER RATHER THAN A FOLD INTO 0.10.0, WHICH IS THE
REASON THE TICKET EXISTS.** The Supervisor decides that an update is available
by comparing `config.yaml`'s version string with the one the box reports.
0.10.0 is installed and running, so a fix delivered inside `## 0.10.0` would
leave every box reporting `0.10.0` believing it was already current - the
PKG-06/PKG-07 no-op, in the form where the tree it hides is a safety fix.
0.10.0's own section argued it could still be folded into; ⚠️ **that argument
was explicitly conditional on 0.10.0 never having been installed, and the
condition has fired** - see the closing note at the end of `## 0.10.0` below.
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons update`
does not walk the versions; it installs 0.10.1 in one step, so every validator
rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as this
section's nothing. Two of them can refuse a config that nobody has edited:
[[SAFETY-39]]'s **rule 10** against 0.7.0's own shipped default `slew_w: 1000`,
and [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
`totalenergies_dynamic` and `eneco_eddy`** - each of which is still refused by
name at startup on this version, with the same message. Both are written out in
full under `## 0.10.0`, and `tools/predeploy_check.py` checks both against your
live options before you press *Update*.
Contents of this release, in merge order since 0.10.0 (`cb3f686`):
| | |
|---|---|
| `b9f7bd0` | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
⚠️ **AND THAT IS THE WHOLE TABLE, WHICH IS THE POINT OF A PATCH NUMBER.**
[[TEL-10]] and [[SAFETY-48]] also merged after `cb3f686`, but [[PKG-15]] folded
both into `## 0.10.0` **before 0.10.0 was installed**, so they shipped under
that number and are recorded there rather than here. [[CI-9]] and [[PKG-15]]
itself touch `.gitea/workflows/checks.yml` and this file only - nothing an
operator can observe on an installed add-on - and are excluded for the reason
`## 0.10.0` already gives. ⚠️ **Two defects that the reference install's own
field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading
message says *"is not following its own command"* when the true cause is an
empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip
on the first read after a rest). Both were open when this section was cut, both
are wording or bound changes rather than deadlocks, and neither was allowed to
delay this one. ⚠️ **[[SAFETY-56]] HAS SINCE BEEN FIXED AND IS IN `## 0.11.0`
ABOVE.** The wording quoted in this paragraph is what **0.10.1 actually ships**
and is left standing for that reason - if the box in front of you reports
`0.10.1`, that is the sentence it will print at you. It is not what 0.11.0 says.
[[SAFETY-58]] is still open.
### SAFETY-57 — the stale-reading verdict gets an exit
⚠️ **WHETHER YOU WANT THIS UPDATE, AND HOW TO TELL.** On 0.10.0 the add-on
can enter a state it cannot leave: the command is parked at **0 W**, the
battery-power reading is gated to **MISSING**, and the only condition that
would clear the gate is one that a parked command cannot produce. **It is a
deadlock, not a slow recovery.** On the reference site on 2026-09-21 it held
for about **thirteen minutes while the house exported 2.4 kW**, and it ended
only because a human restarted the add-on.
**Recognising it costs one look at the status page and one at the log:**
| | |
|---|---|
| the setpoint | sits at **exactly 0 W** and does not move, while the house is plainly importing or exporting |
| the battery-power sensor | is **flat at one number** for the whole of it - on the live episode, −3 W for 850 s |
| the action log | carries a `STALE batt` line and **no** clearing line after it |
**What ends it on 0.10.0 is a restart of the add-on**, and a restart is enough -
nothing is latched to disk and the pack is not harmed. ⚠️ **But nothing ends it
by itself**, and the replay below says the wait can run to hours. On 0.10.1 the
state releases itself after a bounded **32 s** and the register is re-graded
from scratch.
⚠️ **IT IS NOT AN EMERGENCY AND IT IS NOT COSMETIC.** The pack is not at
risk in this state - 0 W is the safe command, and it is the command §11.1 rule 1
asks for on a missing input. What is lost is the site's own control while it
lasts: the battery neither charges nor discharges, so a solar export goes to the
grid and an evening load comes off it.
⚠️ **WHAT IT FIXES, ON THE OWNER'S OWN BATTERY.** [[SAFETY-37]]'s freshness gate
reports a battery-power register as MISSING when it stops following its own
command, and a missing input is `command 0 W and keep commanding` (§11.1
rule 1). The gate's only exit was *"an arrival carrying a different number"* —
**and at 0 W nothing is asking the register to be anywhere.** The state had no
exit reachable from inside it, and the log line said the opposite.
Measured off the RS-485 bus, 2026-09-21, and committed as
`scenarios/safety57-2026-09-21/`:
| | |
|---|---|
| 10:51:24 | 0x0518 reaches **−3 W**. The pack is at 11 %, the loop has just reversed out of a charge, and the BMS refuses the discharge. The register is **healthy and truthful**. |
| 10:51:29 | the command reaches **+1060 W**, 800 W of travel |
| 10:52:00 | the gate arms. `Inverter.batt` becomes `None` |
| 10:52:21 | **the site commands 0 W — on the wire, in the capture** |
| … | the register does not change value for **850 s**. The setpoint does not change value for **790 s**. The house exports 2.4 kW throughout |
| ~11:04 | **a human restarts the add-on.** That is what ends it |
It had been escaping by luck - and ⚠️ **on this episode it did not escape at
all.** `0x0518` answered the same −3 W on 90 consecutive responses and never
once carried a different number between 10:51:24 and 11:05:35. The register did
not move.
⚠️ **AND WHY IT USUALLY DOES MOVE IS NOT SOMETHING THIS REPOSITORY HAS
MEASURED.** An earlier draft of this entry named a BMS keep-alive pulse as the
thing that twitches the register, stated as fact; **no committed capture
identifies such a pulse**, distinguishes one from the register simply following
a command, or sizes it. The honest statement is that the register changes value
for reasons that belong to the battery's firmware. What IS measured is how long
it goes without changing: replaying the committed 16-day loop capture through
the shipped conjunction finds **93 trips** whose wait has a **median of 143 s**
and a worst case of **5 h 57 m**, with **16 of the 93** at or beyond this
episode's **790 s**. That figure is stronger than the firmware story it
replaces, and it needs no firmware story at all.
**The rule, and it is [[SAFETY-13]]'s shape one module over** — which latches
get an exit, not how long the block lasts:
> the verdict is withdrawn when the obligation that produced it has been gone
> for `RELEASE_S`, and the register is then re-graded from scratch.
`RELEASE_S` is **17.0 s**, the obligation window already derived in
`freshness.py` — 6.0 s of plant, one 10 s ESPHome publish, one 1 s add-on read.
It is the **floor** of its band rather than a preference: below it the release
pre-empts the cheaper exit (the register answering by itself), and being wrong
*long* is the failure being fixed. **The whole parked interval is now
`stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by
nothing the battery does. On the live episode: telemetry back at **10:52:39
instead of 11:05:35**, with **0x0518 still answering the same −3 W**.
⚠️ **WHAT IT COSTS, AND IT IS NOT SMALL.** A register that is dead for good is
re-graded for ever. Driven over 1201 reads at the shipped defaults: the held
number reaches a consumer on **630 reads (52.5 %)** against 35 (2.9 %) before.
That is payable only because **one re-grading window is 35 s and the two
consumers hold for 75 s and 85 s** — neither can reach a verdict inside one,
and the re-trip that ends each window hands them a `None`, which *clears* their
windows. **No detection SAFETY-37 had is given back**: a permanent `None`
clears those same windows for ever too. What the site gets back is its
meter-derived control, and the command was never derived from `batt`.
⚠️ **THAT 35 s HOLDS WHILE THE OBLIGATION COMES BACK, AND ONLY THEN - IT IS NOT
A UNIVERSAL BOUND, AND THE FIXTURE THAT GRADES IT CANNOT SHOW OTHERWISE.** A
window closes when the gate re-arms, and the gate needs a command that is
*moving* to re-arm against. If the release lands where the site then regulates
below `MOVE_W`, nothing re-arms, the window never closes, and the held number
reaches a consumer for an unbounded time - where before this card a `None` did.
The driver behind the 35 < 75 < 85 inequality puts a 1300 W obligation back on
every release, so it is the one shape that cannot produce that case. On the
shipped roster of **one** inverter neither consumer can reach it anyway - the
per-unit sign watchdog is compared against the **meter** at `n_units <= 1`, and
`circulating_w()` is identically `0.0` there - so this is **a stated gap rather
than a covered one**, and it needs a second inverter to bite.
⚠️ **WHAT YOU WILL SEE.** A new `STALE WITHDRAWN <unit>` line in the action log
beside the existing `STALE <unit>` one, and a log warning that says in its own
words that **nothing has been learned about the register**. If the pair repeats
every ~70 s, the register really is not following and the unit needs looking at;
if it happens once and stops, the pack was simply empty and the site has
resumed. The stale line's escape clause was also corrected — it used to promise
a clearance the state could not produce. ⚠️ Its **diagnosis** half still says
*"is not following its own command"*, which is wrong in a different way when the
pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately
untouched here.
⚠️ **AND THAT IS NOW A STATEMENT ABOUT 0.10.1 ONLY — [[SAFETY-56]] REWROTE THE
DIAGNOSIS HALF, AND `## 0.11.0` ABOVE CARRIES THE NEW WORDING.** The quotation
above is left exactly as it stands because it is what the 0.10.1 image prints,
and a reader on 0.10.1 needs to recognise it. The same applies to the reading
rule in the paragraph above: on 0.11.0 the add-on no longer asserts that
*"the register really is not following"* — it names both explanations and says
it cannot tell them apart — so *"the pair repeats → look at the unit, it happens
once and stops → the pack was empty"* stops being a correction of the message
and becomes the message's own advice. Nothing about **what** the gate does moved
in either card.
## 0.10.0
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
@@ -87,8 +705,20 @@ check.
**and** is refused by name (rule 11), so the reinterpretation is never
silent; no blob written before 0.10.0 can carry one.
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the
integrator may run ahead of the command on the cycles where the slew limit is
what stopped the command moving. It adds no option, moves no default and
refuses no blob, so every check in this preamble passes it in silence - and it
is the one change here that alters what the inverter is ASKED TO DO on a tree
that is otherwise configured identically. ⚠️ **What it is measured to buy is
less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter
should not be expected to improve.** The whole account, including what it costs
and what it does not establish, is `### SAFETY-48` below.
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS
WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - THE REFUSAL THAT CAN
STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
addons update` does not walk the versions; it installs 0.10.0 in one step, so
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
@@ -109,7 +739,10 @@ fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
`slew_w` against the ceiling and `price_provider` against the withdrawn list
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.**
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of
a run made on 2026-09-19, when the reference box was still on 0.7.0. That
crossing has since been made; the box is on 0.10.0 and the transcript below is
history rather than the state of any install.
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
@@ -120,6 +753,21 @@ BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
live options pass this version's validator
CLEARED for deploy.
⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT.
HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets
folded in below touch three files between them - `app/control.py`,
`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so
the option surface and the 75 defaults the blob is merged over are byte-for-
byte what that run measured. `app/validate.py` imports neither `main` nor
`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and
`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their
quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside
`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half -
the no-op check - did not run at all in that transcript**, because it is
gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is
the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which
the reference box now is**; see the closing note below the contents table.
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
new keys, and none of them can refuse a blob written before it existed:
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
@@ -158,6 +806,44 @@ image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
commits since `86bdf49`**, of which the table below accounts for 21.
⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND
[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH
THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself
rather than by adding to the old figure. The other eight are the three misfiled
tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21
two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is
therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14
counted, so `last..HEAD` excluded it then and includes it now. The three
misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below
moves them.
⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS
NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0
was cut on 2026-09-16, had never been built or pulled anywhere, the reference
install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and
no release - so the string `0.10.0` did not yet name a tree anyone was running,
the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a
number on a release that never left the repository. ⚠️ **IT WAS STATED AS
CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT
ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a
repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a
real battery. **0.10.0 has been built and pulled, and the reference install is
on 0.10.0, not on 0.7.0.**
⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH
IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full
day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation
freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came
out of it that no test in this repository had reached**: [[SAFETY-56]] and
[[SAFETY-57]]. So nothing may be folded into this section again, and
`tools/predeploy_check.py` enforces that by itself: run against a box already
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
which is what a patch number is for. ⚠️ **AND SAFETY-56'S IS IN `## 0.11.0`**,
which is where it went because it landed after 0.10.1 had already been cut. Both
defects named in this paragraph are now fixed; this section, and `## 0.10.1`
below it, describe images that still contain them, and are left saying so.
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
| | |
@@ -180,6 +866,176 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`):
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** |
| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** |
| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS
DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It
changes exactly one file, `.gitea/workflows/checks.yml`, which is not under
`goodwe_controller/` at all: it is not in the built image, it is not in
`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on
on which an operator can observe it. This file is the **add-on's** release note
- the text the Supervisor puts beside the version number - and the rule PKG-15
applied is that a row here has to be something the person deciding whether to
press *Update* could act on. CI-9 is not; the repository's history and PR #95
are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT
THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was
grading the wrong tree, which is the same class of defect this ticket exists to
clean up after - and it is still not an operator-facing change. If a future CI
ticket changes what the shipped image contains, it belongs here; this one does
not.
### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed
⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in
0.10.0 that does so without touching an option, a default or the validator, so
nothing in this section's preamble and no run of `tools/predeploy_check.py`
will warn you about it. `app/control.py` is in the diff; `config.yaml` is not.
**The defect is a missing case, not a wrong constant.** `compute()` freezes the
integrator when the inverter is not following, and the comment beside that
block states the requirement outright — *the INTEGRATOR must stop accumulating,
not only the command*. But `frozen` was computed from one signal,
`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**.
The integrator moves `gain × error` per cycle while the command may move at
most `slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured
median grid error of **1114 W** at the onset of a recorded episode. ⚠️
[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is
a lower threshold.
**What ships** bounds the integrator's **lead over the command** at one slew
step, inside the branch where the slew is what bound the command, and only in
the leading direction. Nothing else in the law moves: the output clamp, the
saturation freeze, the deadband and the 10 W quantisation are unchanged, and
`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the
transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not
`target`.
⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER
CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed
over 256 252 recorded meter instants spanning 16.00 days of this install
(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same
disturbance, same plant, same seed:
| | release/1.0 | SAFETY-48 |
|---|---:|---:|
| **export kWh** | **39.61** | **34.07** |
| wasted throughput kWh | 4.67 | 3.19 |
| battery sign reversals | 2992 | 2770 |
| median recovery s | 232 | 199 |
| unrecovered (>900 s) | 53 | 31 |
| worst \|i_w − prev_w\| W | 9200 | 800 |
That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the
direction is what is robust, not the size**: across every plant and tuning
variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a
re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the
mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because
`|error| > slew_w / gain` is rarely satisfied in those regimes and there is
little for the cap to bite on.
⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above
also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and
that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s
inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**,
while the instrument's own fidelity measure — replayed export against the
**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 %
gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best
reproduces the recorded export, release/1.0's worst quarter is already 3434 W
and this change moves it by exactly zero.** The lag is not identified by this
recording, and identifying it needs a driven step response that does not exist
yet. **What this release can say about the billed quantity is that it does not
make it worse at any lag tried — not that it improves it.**
⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant
model with a 375 W RMS residual; it prints its own fidelity first and it
reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it
is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed.
Nothing here drove an inverter.
**What it costs, stated rather than smoothed:**
* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs
before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks
it changed `i_w` on **1095**, and on **679** of those it moved it further out
on the side it was already on — putting back magnitude the freeze had just
let fall, which is the one direction the freeze's own rule forbids. Worst
case **2424 W**. It never overrides a *same-cycle* refusal.
* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at
−800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because
there is less wound integrator to settle onto; the property asserted is that
it stops at the frozen integrator rather than at the rail.
* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption"
is now FALSE — inverted**, on the independent first-order plant
`test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a
slower slew becomes 729/550/424/326 falling. Two plant models that share
nothing agree on the sign. SAFETY-39's record carries a forward reference to
this one.
* **The form the card originally asked for was rejected on measurement**, and
this is the least lag-sensitive result in the work: reusing the asymmetric
freeze on a carried slew flag moves the worst quarter-hour offtake
3739 → **3935 W** at the fitted plant, and across the lag sweep it is the
worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 /
4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag
0.0, and it is worse at **every top-N cut** of the quarters. It also raises
the mean quarter (+2.73 W).
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it,
including §8, *what this card does NOT establish*.
### TEL-10 — the loop's integrator stops being invisible
**Three new MQTT sensors, and nothing in the control path moved.**
`app/control.py` is not in this diff: `compute()`, its returned `Decision` and
every constant it reads are identical to release/1.0.
`self.i_w` has been a separate quantity from the command since [[SAFETY-04]]
and left the process **nowhere at all** — no entity, no attribute, no log line.
The investigation behind SAFETY-48 could only *reconstruct* it from the grid
trace plus a replay; the reconstruction reproduced about a third of the
engagements it was checked against and was discarded, so a control card had to
be argued from a measurement nobody could stand behind. It could not have been
made to work, either: `i_w` depends on every cycle before it, including the
ones where the arbiter overrode the loop and `main.py` re-seeded it — and that
re-seed is invisible in the grid trace.
| entity | topic | unit | what it is |
|---|---|---|---|
| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation |
| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing |
| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` |
⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH
IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after
every strategy has had its say, and it is **this** cycle's; the lead is
measured against the command `compute()` was given, which is the previous one.
On any cycle the arbiter overrode the loop the two differ — and that is exactly
the cycle on which the integrator was re-seeded, so a dashboard doing the
subtraction would get a different number precisely where it matters.
⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of
those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the
winning claim and its watts and has never carried that claim's own reason.
`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in
neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.
⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are
**appended** after the nine messages release/1.0 already announces, and all
nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is
what `test_mqtt.py` part 1's prefix comparison grades; the ninth,
`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**,
so a prefix comparison cannot cover it and the check on the next line pins it
in its new position instead. **They are SITE rows, not per-unit ones** — there
is one integrator, so a per-unit copy would invent N of a thing there is one of
— and they therefore appear at **every** roster length, **including a roster of
one**, where the per-unit machinery announces nothing new at all.
Both numbers carry `state_class: measurement`, so the recorder keeps long-term
statistics for them. `DOCS.md` has the operator account, including how to read
a standing lead: the cycles needed to unwind it are that lead divided by
`slew_w`.
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
@@ -285,32 +285,11 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
card changed there is **which** roster entry the claim is filed on: the one whose
state machine produced the result, rather than `inverters[0]`.
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
Measured while wiring this card, and **pre-existing on `release/1.0` at
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
refusal with the two unit names swapped.
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
When the site target is *below* the pin — a 2000 W drain while the house imports
less than that, which is most of a night — the only split that sums to the site
target asks the sibling to **charge** while the maintained pack **discharges**,
and `allocator.plan()` refuses it by name as circulating power. The refusal is
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
line in the action log.
**The consequence an operator would see is a misdiagnosis.** The pack is held at
0 W while its state machine believes it is draining, so after 12 h the DRAIN
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
following commands. The battery is fine; the site refused to allocate.
**Why it is not fixed here.** The fix is to change what the site loop's target
*means* while a unit is pinned — regulate the meter using only the unpinned
packs — which is loop and allocator territory and changes the integrator's
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
its own.
Found here and pre-existing: below the pin the other pack had to charge, the
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
@@ -0,0 +1,129 @@
# OPT-11 - battery modes, per battery
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
it; a limit above 35 (the peak guard) makes it give way only as far as that
limit needs, and a battery its mode holds back then carries only what the
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
P1 is its section 6 P1, and the pull request lists what was measured.
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
a5dddad (`test_modes.py` part 1).
- Off is a mode range, so the peak guard can still use an Off battery when the
others cannot cover the floor (design sections 2-3). Whether Off should be
absolute is an open owner question.
- Review (PR #149): releasing only when the peak floor bound S let a trade won
above the floor keep the modes and place less than the floor, and let a
capped grid charge release an Off battery. Both are graded in
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
rail to the rail, so SAFETY-03 still flags a loop winding into it.
- P2 (HA entities): a select and a switch per battery, only while modes are
on; commands go through `handle_action()`, the page's path. A retained
command is dropped, not applied: the store already holds the last choice,
and a replay after a restart could otherwise undo a newer one. The config
topics announced are kept in the store (`mqtt_mode_configs`), so turning
modes off removes exactly those, and an install that never turned them on
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
in that list and its empty config is re-sent on each start, so a start with
no broker cannot lose it; re-sending is harmless. The HA options are the
page's English names, pinned by a test, and are not renamed with the page:
automations send them.
- In P1 only Off closes a direction; Leftover solar differs by charging last.
- P3 (price runs, `priceruns.py`, `modetrade.py`): each battery's range is
narrowed by its role this tick (hold, give, charge, rest, sell); main.py
files the intersection with the switch, so it still holds 0 W. With modes on
the site trade is filed by the modes, sized from the loop bound (so no
battery a mode or a refusal holds is asked for watts, P1's unmet share) and
the guard's headroom; `arbitrage.decide()` runs only with modes off, which
stays byte-identical to 7a00f12 (`test_modes_p3.py` part 1).
- SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is
a run of consecutive firm slots. It applies with battery modes on; the
modes-off trade keeps its one-period shape, because that path must not move.
- Choices that were measured, not assumed (tools/opt11p3_replay.py):
- a sale needs the guard to have measured the quarter, not to be clear: a
5 kW discharge alone reads "engaged" on export and flipped the sale every
5 s;
- a grid charge makes the others rest, so it starts only at 500 W and, once
the guard or the headroom stops it, stays off for that 15-minute period
(without: 13 flips in 4 minutes under a 3.5 kW house);
- stored energy is valued at the last cheap run for 12 h, and a charge
needs its own run to clear the spread (without: a night charge was spent
on flat morning prices, then charging went on at them);
- a run edge reseeds the loop at the house's level (a 3 kW import spike
removed; about 0.1 x rating still overshoots as export).
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
trade 18.14. Review of #153 (same model, with a per-action split):
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
expensive hours saved 4 kWh of import, not enough. With no grid charge on
Leftover solar: 19.44;
- the old trade charged one hour, so the afternoon sun still fit;
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
then bought the same energy back (2.29 EUR): a wash before wear. It pays
only when the sale hour's wholesale price beats the later import price,
levies included (about 0.13 EUR/kWh above that hour's wholesale here);
- valuing the charge left at the end, wear and real Flemish levies make
selling worse and do not change the order.
- Owner decision 2026-09-29 (option A): selling is off until it can be judged
on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
Leftover solar takes solar only until P4's forecast
(`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed
(16 days, as above): Solar first + Leftover solar 19.44 EUR net against
P2's 19.33; Solar first + Cheap hours only 19.57.
- Not done in P3: the forecast (P4), and a headroom too short for the run
moving the rest to the next cheap periods.
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
older than 3 h is stale (one missed hourly poll must not flag it).
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
keeps grid + battery per local hour for 7 days in the store
(`house_profile`), median per hour. It is net of the solar the house used,
so the solar sum never takes it below the night median. Solar readings are
not used (TEL-11). The first plan needs one day of it.
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
until the expensive run the cheap run is for. Solar first takes it first.
- Leftover solar may then charge from the grid up to min(ceiling, floor +
the house's use in the expensive runs / 0.95), less its sun share: only
the shortfall.
- Cheap hours only ignores the plan and fills to the ceiling (design
section 1; review of #156, see the replay below).
- Solar first never charges from the grid (owner, 2026-09-29).
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
(no, stale or failed forecast, no house profile, no capacity) every mode
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
5a4cdc2 (modes on, with and without an unavailable forecast).
- The plan is made with the runs (every 15 min, or at once when the forecast
comes or goes). A lost forecast drops the levels at the next cycle.
- Not done: the design's scaling by measured solar from 10:00 (it needs the
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
FCST-01.
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
solar, net EUR, lower is better; site solar rebuilt from the recording,
forecast errors from Elia's day-ahead Belgian solar forecast):
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
missing 19.44);
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
32.01).
- Solar first + Cheap hours only, skipping its sun share (the first
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
moved runs left B lower, so later midday top-ups rested A while the sun
shone. Cheap hours now ignores the plan, which is P3 exactly.
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
gain is Leftover solar buying the evening's shortfall in the cheap run;
the forecast itself was worth 0.14 EUR in 16 September days.
- Bias: the site's solar is rebuilt from the same net use the replay
plays, and "recorded" keeps its hourly shape and adds only the national
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
No threshold was tuned to these days. The 2026-09-24 PV recording could not
be replayed: it is only in the live Home Assistant.
@@ -0,0 +1,28 @@
# SAFETY-40: the integrator bound is not a bug
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
notified on the same cadence, and escalates on a sustained run
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
the `ERROR` and the red row.
**Why it still notifies.** On a default install `clamped` is unreachable
(`control.py`, at the integrator bound), so the integrator bound is the only
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
remove that. The card says the same: "the fix is to say the true thing, not to
say less."
**Why a bug has its own budget.** A genuine over-rating must not wait up to
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
text within an episode. Across episodes the WARNING uses its own
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
before (`test_clamp.py` part 9).
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
notify at all, or only log and count. They notify now, as before.
@@ -0,0 +1,110 @@
# SAFETY-52 - a battery that refuses is asked for 0 W
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
questions answered yes.**
## The defect
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
inside the interval a limit can produce. The request stood at +5000 W for
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
moved again only when the grid error changed sign. SAFETY-48's lead cap only
changes where `i_w` parks (one slew step ahead instead of at the rail). When
the request moved after the reading went flat, the stale-reading gate won
instead and ran SAFETY-59's ~70 s trip cycle.
## What was decided
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
`saturation_w`. The drop to 0 skips
the slew limit (it only removes an ask nobody meets). The direction is
carried like `sat_count` (`refused=`, `Decision.refused`).
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
cycle. The log keeps SAFETY-56's two-explanation wording.
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
Other exits: the loop wants the other direction (charge is accepted at the
floor), control stopped or started, another writer on the wire (the arbiter
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
what seeds it.
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
battery's discharge end and moves its share. There, a one-unit gate trip must
not refuse the site. `compute()` is passed `refused=None` there, and that path
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
`test_control.py` still holds).
## Evidence
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
shapes, recovery with no sign change, the probe cadence, every exit).
`tools/replay_refusal.py` replays the 16-day capture, same model as
`replay_recorded_loop.py`, and should be read for deltas:
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|---|---|---|---|---|---|---|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
just above its floor, the probe is answered, control ramps, the pack empties
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
follow an other-direction exit the same way. On a site this is one
`STALE_READING` ERROR line per answered probe at most, and only on a battery
that answers a probe and then cuts out again. Catching the re-cut before the
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
battery, so it is left to SAFETY-58/59. After the review changes below the
fitted-plant row reads 133 trips (the delivery exit now also catches answers
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
the phase A plan: bounding the integrator by demand made the worst quarter
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
configured floor to be right and needs SoC.
## Review changes (#141)
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
old law. The capture has 0.11 h in that band, so the replay does not move.
- **Any delivery ends the hold, not only an answered probe.** With
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
held it for 15 min. Now the late reading ends it within seconds (2 s held in
`test_refusal.py` part 5b).
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
battery only when its cut-out level is configured right.
## Not covered
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
genuine limit (SAFETY-13 case a) and still holds its request.
## What SAFETY-58 and SAFETY-59 must take into account
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
the first verdict, the seed arrives later. The 09-19 shape then needs the
compute-side engagement (12 cycles) to win, which it does only while the
request stands still. Keep `test_refusal.py` part 4 green.
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
change a refusing battery no longer produces the pattern at one inverter. The
DOCS text should say so, and that the `battery refuses` reason is the
discriminator the add-on now has. At N>1 the old pattern can still occur.
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
`test_refusal.py` part 2 checks the inequality.
@@ -0,0 +1,181 @@
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
**Text only. No behaviour changes in this card**, and §5 is the measurement that
says so: the same stimuli through the tree before and after produce a
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
and every readiness row's `(ok, warn)`.
---
## 1. The defect
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
healthy hardware has failed.
**The log line**, as emitted on the reference site on 2026-09-21:
> SAFETY-37: inverter-a battery-power register **is not following its own
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
> as MISSING rather than as a measurement … Treating it as MISSING, which
> commands 0 W.
**The readiness row**, at the same moment:
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
> **is missing or not numeric**
That entity was present, numeric and updating.
**SAFETY-37's conjunction is correct and is not touched by this card** —
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
genuinely stuck against a real command is exactly what it should catch, and a
pack that cannot comply is a state the site must stop in too. What was wrong is
what the sentences **concluded** from it.
## 2. Two worlds satisfy the conjunction
| | the register | the pack |
|---|---|---|
| **DEAD** | has stopped reporting reality | is doing what it was told |
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
A refused pack holds still under any setpoint, so an honest register publishes
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
separates them.** All three sentences asserted DEAD.
## 3. The live episode was REFUSED, and it is measured, not argued
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
| | |
|---|---|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
| pack SoC throughout | **11 %** |
| first **charge** command after the restart | 11:05:31 at **-400 W** |
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
**A register that answers a reversed command inside one obligation window had
not stopped reporting.** What would not move was the pack. The sentence the
operator read named the one thing in the loop that was telling the truth.
⚠️ What this does **not** establish: that no register is ever intermittently
wrong. It excludes a register that had simply stopped; it cannot exclude one
that was wrong and then right. That is the point — the new sentence claims
neither.
## 4. The fix, and why it is three sentences and not one helper
Each message states **what was observed** and names **both** explanations.
| where | before | after |
|---|---|---|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
**Three sentences, three wordings, no shared helper.** The first two are a log
line about a **behaviour**; the third is a banner about a named **entity**, and
the two answer different questions ("what did the register do" against "can I
trust this entity"). A helper serving both would have to drop whichever half
the other caller does not need, and the half it dropped would be the
operator's next step. The log line names the pair **once**, inside the `%s` it
already interpolates, rather than in both of its own halves.
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
asserts the sentence does not move one byte.
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
object at the same moment, and the row deliberately does not ask it: the same
loop renders the grid and SoC rows, which have no gate, and a branch per row
would be a second place where "why is this None" is decided. The gate already
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
the whole STALE_READING sentence in it — so the operator's discriminator is
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
and absent in ABSENT**.
## 5. Behaviour is byte-identical, measured
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
`origin/release/1.0` **archived out of git** and against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
unchanged" unless the `sentences` halves **differ**. This card's first
invariance run was exactly that mistake — it archived a branch whose edits were
not committed yet, so `before` and `after` were the same code and the identity
was perfect and worthless.
Both of the tool's refusals are **driven**, not asserted:
| | |
|---|---|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
## 6. The mutation battery
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
graded by `tools/mutation_run.sh` in the CI image.
| | mutation | Δ bytes | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
| M5 | the second explanation deleted, the first kept | -216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
| M8 | the row's second cause deleted | -309 | **1** |
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
⚠️ **M9's first run was a defect in this card's own check, and it is the one
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
with **no name attached**, with every check after that line **ungraded**. That
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
raising, and a precondition grades it: **0 named reds before, 3 after.**
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
True, and so is one over three mistyped literals. The positive control runs the
same predicate against the three sentences **as 0.10.1 shipped them** and
requires it to find an accusation in every one, and every literal to match at
least one of them (habit 7: vary one thing from an accepted baseline).
## 7. What part 11 does not grade
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
* It does not grade English. A sentence that named both explanations and then
said something useless about them would pass.
* The `no entity configured` branch beside the rewritten row is untouched and
ungraded: a blank entity really is the only thing it can be.
* The **grid-power and battery-SoC rows** go through the same rewritten line and
have no freshness gate today, so for them the second cause is a hypothesis
about this controller rather than a live possibility. That is weaker than the
battery-power case and is the price of not branching per row. A gate on either
signal — TEL-08 would be one — makes it exact.
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
healthy register could answer), and neither is the 710/790 disagreement
between `test_freshness.py` and the changelog. Both are separate cards; the
second has a **fourth site** at
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
off the PR table alone would miss.
@@ -0,0 +1,388 @@
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
(content-identical to `release/1.0` @ `8a57361`).
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
**passive** RS-485 listener with no address on the bus, and it was read only.
---
## 0. The five things a reader should take away
1. **The live episode is reproduced from the wire, not modelled.** The capture
is committed, the decoder is committed, and the box that produced it was
running the defect — so the `0 W` in the trace *is* the add-on's own
`inputs missing → command 0 W`. §2.
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
already holds, and it is not the bug.** `_grade()` has required
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
the stuck state is at **0 W**; they are different moments. §3.
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
when the **obligation that produced it is no longer standing** — SAFETY-13's
shape one module over. The 17.0 s is the obligation window that was already
derived in the file, taken at the **floor** of its band, and which half is
safe is the **opposite** of SAFETY-13's. §4.
4. **It costs 52 % exposure on a permanently dead register, and that number is
in the suite so it cannot be forgotten.** It is payable only because one
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
**No detection SAFETY-37 had is given back.** §5.
5. **The battery had been rescuing this code by accident.** 93 trips in the
committed 16-day capture; median wait for the BMS keep-alive pulse that
cleared them **143 s**, worst **5 h 57 m**. §6.
---
## 1. What was there, in the terms the code uses
`freshness.py`, `Freshness.submit()` and `_grade()`:
```python
if self.changed_mono is None or value != self.held:
...
self.stale = False # the only place the latch is cleared
def _grade(self, standing_w, now_mono):
if self.stale or self.changed_mono is None:
return # sets the latch; never clears it
```
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
missing")` and keeps filing it.
The reason the latch exists is written in the class docstring and it is a good
one: without it the verdict oscillates at about 1 Hz, because declaring the
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
which hands the held number straight back.
**The defect is that the only thing that ends it is an arrival carrying a
different number — and the state's own output is what stops the register being
asked for one.** The log line promised the opposite, in the same sentence that
announced the 0 W:
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
> register reports a different value.*
---
## 2. Reproducing it, from the wire
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
rather than trusted: 298 commands at or under −1000 W are each answered one
obligation window later by a negative register reading, and not one positive.
| capture time | on the wire |
|---|---|
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
| …850.0 s | `0x0518` does not change value |
| …790.0 s | the setpoint does not change value |
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
| 11:05:34.7 | `0x0518` = **−137 W** |
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
this is a reproduction and not a simulation: the box was running 0.10.0, so
every command in the trace is the shipped code's own. The only event inside the
window the file cannot account for is the human restart.
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
(`test_freshness.py` part 10b):
| | stale at | clear at | parked |
|---|---|---|---|
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
only ever called where the register moves, and the window pair is the
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
asserts that by driving part 1's own fresh/stale pair through both bounds and
comparing the verdict streams.
The replay also found a **one-read trip at 10:48:14** that clears one second
later. That is a pre-existing false positive and it is now recorded as
survivor 5 — §7.
---
## 3. What the card's option 2 actually buys, which is nothing here
The card offered *"do not arm the gate when the command being graded is 0 W"*
and warned that it might hold already. **It does, and it has since SAFETY-37**:
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
`test_freshness.py` part 4 has a named check on that term, added because a
mutation removing it survived.
The warning in the card is the right one and it is worth writing down: **the
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
where the conjunction is entirely correct — a command travelled 800 W and the
register did not move for 35 s. The site then went to **0 W** *because of the
verdict*. An entry-side rule cannot reach a state that is entered legitimately
and then persists. **The defect is the exit, and nothing else in the conjunction
is touched by this card.**
Option 3 — probing with a small non-zero command — was not taken and does not
need to be. It writes to a real actuator to test an assumption, and §4's rule
does not need a probe: the site's *normal* control resuming is the test, and it
is gated behind the same `inputs_ok` everything else is.
---
## 4. The decision: which verdicts get an exit, and what the bound is
> The stale verdict holds as before, **unless** the obligation that produced it
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
> longer than the register would have needed to answer it. On a release the
> latch clears and a **new grading window** opens at that instant.
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
established the house answer to "a latch with no exit" and its answer was *not*
a plain time bound but a **predicate plus a time bound**, where the predicate
says whether the state can end on its own. Here the predicate is that **the
question is no longer being asked.** A register under a 0 W command is obliged
to be nowhere, so a verdict about whether it is following cannot be tested, and
a verdict that cannot be tested may not stand for ever.
### 4.1 Why the window has to re-open, and what happens if it does not
`_grade()` compares the register's unchanged time against `unchanged_s` and the
command's travel against the command standing when the window opened. If a
release cleared the latch **without** re-opening the window, the first read at
which the resumed command travels 250 W would re-trip immediately — the
register would never get the 17 s in which it could answer, and the live
episode's recovery (a charge the pack **accepts**) would be killed inside two
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
+82 B) takes four named checks red.
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
because it is the same question — *does this register follow the commands issued
from now on?* — and there is no principled shorter answer to it. It is also the
number the exposure argument in §5 rests on, so it is not free to move.
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
The quantity needed is *has the register had its chance to answer the command
standing now?*, and `freshness.py` already derives it as the floor of
`BATT_UNCHANGED_S`'s band:
```
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
1.0 s this add-on run_control()'s await asyncio.sleep(1)
```
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
| | the question | the safe half | why |
|---|---|---|---|
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
the new 0 W command by itself, which clears the latch on evidence rather than on
a timer. That is graded behaviourally rather than asserted: a register that
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
still 0, and by **timer** at `release_s = 5.0`.
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
six distinct release instants, so no check in the file can confuse two of them.
### 4.3 Two clocks, and the operator reads the register's
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
release they go on reporting that it has not moved for 850 s, because it has
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
until the first release. Collapsing them either lies to the operator or defeats
the re-grading, and both directions are mutated red.
The stale sentence's `unchanged for N s` slot carries the **register's** number;
a re-trip appends a clause naming the window and the grade count, so the two
durations cannot be read as one number that moved (habit 14).
---
## 5. What it costs, measured
Driven against a register frozen for good, 1201 reads, shipped defaults
(`test_freshness.py` part 10c):
| | parked | the held number reaches a consumer | releases |
|---|---|---|---|
| before | 1166 s | **35 reads (2.9 %)** | 0 |
| after | 571 s | **630 reads (52.5 %)** | 17 |
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
It is payable because of one inequality, and it is the inequality
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
SAFETY-37:
```
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
```
Every unbroken run of exposure is **exactly one window**, so neither consumer
can reach a verdict inside one, and the re-trip that ends each run hands them a
`None`, which **clears** their windows rather than accumulating into them.
**And nothing SAFETY-37 detects is given back**, which is the half that makes
this a trade rather than a regression: a permanent `None` clears those same two
windows for ever as well, so on the shipped-before module those detectors are
*equally* unable to speak about the unit. What the held number does reach for
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
answers a large unexplained divergence by **freezing the output**, the
conservative direction and SAFETY-13's business — and the command itself is
derived from the **P1 meter**, never from `batt`.
### 5.1 The narrowing this does NOT take, named so the next card can
The live episode's register refused a **discharge** into an 11 % pack and then
followed a **charge** three seconds later. A refusal in one direction is not
evidence about the other, and a gate that knew the pack's floor could re-grade
on the first command that crosses it instead of on a timer — which would shrink
the 52 % above. That needs SoC, which this module deliberately does not take
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
It is **not needed to give the state an exit**, and folding it in here would
make the exit depend on a second measurement being right.
---
## 6. The measurement that says the old exit was luck
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
thing that could clear any of them was the register carrying a different number,
and that took:
| | |
|---|---|
| minimum | 0.6 s |
| **median** | **143.4 s** |
| maximum | **21440.1 s** (5 h 57 m) |
| at or over the live episode's 710 s | **17 of 93** |
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
Over a fifth of the trips are cleared in under 20 s by something nothing in this
add-on asked for.
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
setpoint column is an **ungated** controller's. That makes each trip an honest
register-versus-command divergence off the real bus, and it makes the post-trip
command column something a gated box would **not** have written. The figure
taken from it is therefore the only one that does not depend on the command at
all — how long this register goes without changing value — and it **under**-states
the wait, because a site parked at 0 W gives the register even less reason to
move than the one recorded here.
---
## 7. Survivors, and the two things the battery found
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
invocation and the tree is fingerprinted **after the revert** as well as before;
every revert restored the recorded sha256 and byte count.
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
PR body. Two of them are worth keeping here.
### 7.1 The equivalent mutant, argued rather than rounded off
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
survived the whole battery. **It is not a missing fixture and no fixture can
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
only when `|standing| < move_w`, so at most one of them can do anything on any
read, whichever runs first.
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
a comment is a claim, and this one had not been run.** The comment now states
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
1300 W — instead of pinning an order that is not observable.
### 7.2 The sibling that survived, which is habit 11 earning its place
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
below both 35 and 250. They separate only in the band between them, and a site
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
that asserts the 100 W case releases and the 300 W case does not.
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
The first form of the `graded_from` mutation was
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
the tree fingerprint showed the edit really was on disk and really did run.
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
has nothing to do with the property under test. Rewritten as an explicit
`is None` test (+82 B) it is red on four named checks.
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
purge, a fingerprint and a byte delta all say the edit is present; none of them
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
for it.
---
## 8. What this does NOT fix, said plainly
**It does not tell a BMS refusal apart from a dead register**, and it does not
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
would need.
**It does not make the diagnosis right.** The line the operator reads still says
*"battery-power register is not following its own command"*, which is wrong in a
different way when the pack is simply empty. That wording is **SAFETY-56's**,
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
two cards editing one sentence is how one of them silently loses. Only the
**escape clause** was rewritten, because this card is what made the old one
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
> the deliberate act: all three sentences now name **both** explanations and
> accuse neither, and part 10h's check is inverted — it requires the accusation
> to be **gone**. Nothing else in this section moved; the escape clause above is
> still this card's and is untouched by it. See
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
> `test_freshness.py` part 11 for the graded pair of worlds.
**Survivor 5 is older than this card and is not closed by it.** After a long
rest the unchanged clock is already past 35 s, so the **first** read carrying a
travelled command trips the gate — before the 17.0 s in which a healthy register
could have answered it. The live capture contains one (10:48:14, cleared one
second later). Its size is one `stale_input_s` hold, during which `cycle()`
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
means measuring the unchanged clock from when the **obligation** appeared rather
than from the register's last move, which re-derives the 35.0 s bound and is a
different card.
**Nothing was measured on two inverters.** Inverter B does not physically exist
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
copies of A's behaviour with different numbers passed in.
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
requires the newest heading to be a version. Moving it, and deciding whether a
version is cut, is a PKG card's.
@@ -0,0 +1,106 @@
# SAFETY-61 — the control task's death ends the process
## 0. The defect
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
and tariff tasks kept running, and the Supervisor saw a healthy container. The
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
indefinitely while the add-on showed green.
`test_control_task.py` reproduces this on the pre-fix tree. The child process
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
## 1. Decision: exit with status 70 after the ordinary wind-down
A done-callback on the control task (`control_task_done()`) handles the death.
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
`command_site_zero()`, then cancel, then clean up). After that it raises
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
`stop` before the task ends, so the callback ignores them. The refusal still
exits with 78.
**Why not restart the loop in the same process:**
* `cycle()` writes controller state before its awaits: `cycle_at`,
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
exception between those steps leaves the controller half-updated. A loop
restarted in the same process would continue from a state that no boot ever
produces. A process restart rebuilds the controller from the Store, which is
the path every boot already runs.
* An unbounded retry is the invisible spin the card forbids. A bounded retry
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
30 min, and 5 attempts with backoff (`apps/const.py`).
## 2. What happens after the exit, verified rather than assumed
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
uncaught exception gave 1.
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
source read from the image):
* A non-zero container exit is recorded as `ContainerState.FAILED`
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
* The Supervisor restarts the add-on **only if the per-install Watchdog
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
The toggle defaults to **off** (`apps/validate.py`:
`vol.Optional(ATTR_WATCHDOG, default=False)`).
* Add-on containers get **no Docker restart policy**. The only
`restart_policy=` in the Supervisor belongs to the observer.
* `boot: auto` in `config.yaml` only controls starting at host boot. The
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
policy, and this add-on does not set it.
* **So on a default install the add-on stays stopped after this exit.** The UI
shows it as stopped, its log holds the traceback, and the Supervisor log holds
an ERROR line. The pack is at 0 W, which is the same physical state as before
this card. The difference is that 0 W is written immediately instead of after
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
on, the Supervisor restarts the add-on.
* **Not verified:** an actual restart by a running Supervisor. That would need a
live Supervisor, and the only one available is the live house.
## 3. Left open for the owner
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
this card's file.
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
was rejected here, because it would change a user setting without the user
knowing.
## 4. Scope: the siblings, and why they are not supervised
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
`Exception` inside their loops by design, and the values they feed are
age-bounded downstream.
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
long-lived task's end exit the process would turn that deliberate fail-safe
into a restart loop.
* Only the control task is supervised.
## 5. `hass.py`: an undecodable body is an absence
A non-JSON `200` served as `text/html` was **already** treated as an absence,
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
escaped were these three:
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
(a `ValueError`);
* a JSON body that is not an object, which raises `AttributeError` in
`number()` and `limits()`;
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
All three were measured against aiohttp 3.13.5. The first two now read as
`None`, and the third is now a failed write (`False`).
## 6. Status row
`checks()` now starts with a *control loop* row showing how long ago the loop
last completed a pass. The row turns red once that age exceeds
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
layer 1 has zeroed the inverter.
@@ -0,0 +1,104 @@
# SAFETY-73 - a battery that refuses, with two or more inverters
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
new questions answered yes. SAFETY-52's four decisions carry over.**
## The defect
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
its state of charge reads above its configured cut-out, so the allocator kept
handing it its weighted half. Measured on the real `Controller`, two units,
house 3000 W, A cut out:
| register | law | B gives | grid | A's gate trips / h |
|---|---|---|---|---|
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
| jitter | old | 1910 W | +1094 W | 0 |
| jitter | new | 2000 W | +1004 W | 0 |
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
(+1910 W each on the jitter shape), new holds the site at 0 W.
## What was decided
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
claim, source `refused`. It engages when the unit gives under
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
(200 W), or when its stale-reading gate
trips on a held reading under 100 W. The allocator then hands its share to
the others within their own limits (owner decision 1). It is a hard unit
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
arbitrage and maintenance are placed around it like the loop.
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
unit: its share is 0 W, and `account()` treats it as parked.
4. **All units refusing the same way** pass that sign to `compute()`, so the
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
time.
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
others carry the rest. The pin stands only while the resolved site target
asks at least 200 W its way; otherwise it would make a sibling take the
opposite direction, so it falls back to the closed limit for that cycle.
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
100 W or more taken the other way for 12 cycles (charge at the floor; one
cycle of the loop overshooting through zero is not enough); stop or start.
On every exit the site target and integrator restart from the watts on the
wire, or the reopened unit is handed a parked remainder at once.
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
night load split two ways asks 200 W of each. With 500 W the empty battery
kept a phantom share that SAFETY-43's relative arm then reported as not
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
under 100 W are "too small to call" in `account()`: the sibling covers the
meter and nothing is asked of the empty battery that matters.
**Why one direction throughout.** On TEST-19's flapping family a command that
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
no-fault runs without the rule, 0 with it.
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
SAFETY-73 method made to raise.
## Evidence
- `test_refusal_units.py`: cases a, b and c on both register shapes against
the old behaviour, probes and their spacing, one-at-a-time probing, the
recovery reseed, each exit, the small-ask and small-site-target cases, the
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
unit executing its share) graded with the engage clause: 0 engagements, the
longest qualifying streak 5 cycles against 12. The grader is
`goodwe-session/scratch_s73/s73_nofault.py`.
- **Recovery.** Case a, A recovering into a house that fell below the parked
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
reseed and the site hold.
## Review changes
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
10-17 s publish). The reviewer's harness (real `Controller`, both units with
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
refused both healthy units 10-16 times an hour, and each time the site held
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
- **The cut-out WARNING is for a refusal to give power only.** A full battery
that stops taking charge is always above its cut-out level, and the WARNING
told the installer to check a setting that has nothing to do with it.
## Not covered
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
N=1. SAFETY-42/43 still report it.
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
refusing battery counted. The shortfall shows as `unmet_w`.
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
@@ -0,0 +1,44 @@
# SAFETY-78 - a maintenance drain at two inverters
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
**The defect.** At N>=2 the drain pins its pack (a unit SET at
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
site target was below the pin, the other pack had to charge. The allocator
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
drain rate, a grid-charge trade, and a house above it too: after a refusal the
loop restarts from 0 W and its 400 W slew step is below the pin again.
**The fix.** While a pack drains at N>=2, main.py files a site limit
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
target is never below the pin and the other pack only adds power in the same
direction. Opposite splits stay forbidden.
- The order is the arbiter's own: a safety SET (100) outranks the limit and
the pin, so everything parks; the peak floor (50) and the drain floor are
both applied, so the higher wins; a trade or the loop below the drain is
lifted to it.
- Below the drain rate the drained pack exports the rest, as the drain does at
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
to the house load would make N=2 drain differently from N=1; that is an
owner question, not this fix.
- A draining pack that can place nothing (a refusal, a stale link) adds no
floor, and the wait is logged once per episode.
- With battery modes on, no pack is a grid charger while another drains
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
says why (`other_cycle`). Not while the drain waits: no floor is filed
then, so the other battery may charge from solar.
- Not filed at N=1, where the drain is the site SET itself: byte-identical
(`tools/safety78_identity.py`, which hashes the claims too).
**Not changed (measured in the PR #154 review, follow-ups).**
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
4000 W. Pre-existing, kept by the N=1 identity.
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
charging pack's `hi = -floor` against the loop asking the other to give is
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
the peak floor's `lo` (0 W), so it is not this card.
@@ -0,0 +1,41 @@
# SAFETY-79 — the peak guard keeps a margin below the peak limit
**Status: DECIDED 2026-09-28 (owner decision).**
## Decision
The peak guard aims `peak_margin_w` under the limit it holds: under
`peak_cap_w`, or under a month peak already banked above it
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
0-500 W, whole watts. An install without the key gets 50.
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
not a constant because the add-on is sold to several sites.
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
peak as it stood. That can bank a few W over it, which raises the bill the
same way; and a peak banked between cap - margin and the cap (common in the
month an install upgrades, since the old guard held quarters at the cap) was
aimed at exactly, so one overshoot went over the cap. The margin now applies
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
unused (more discharge, less charge headroom); no bill cost. Undo is one line
in `quarter_target_w()`.
## What uses which number
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
checks, via `Controller.peak_aim_w()`).
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
status row built from it, Home's limit, and `validate.config_notes()`.
## Checks
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
digests from tools/safety79_identity.py, which also runs a maintenance charge
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
whole, or not below `peak_cap_w`.
@@ -0,0 +1,63 @@
# SAFETY-81 — no false "Load too high for battery" alarms
**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance
as a constant and added the 10 s hold.**
## Defect
The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7.
The alarm fired whenever `decide()` clamped its floor to the rating
(`saturated`). Three things set that while the quarter landed on target:
- **Double count.** `decide()` sizes from the held meter reading plus the
battery reading. When the meter sample predates the battery's last increase,
the increase is counted twice. It happens under noise, and at each quarter
start, where the battery is back while the held sample is still the spike.
- **The aim, not the cap.** `saturated` is graded against cap − margin, so at
margin 50 noise needs 50 W less to trip it (F2).
- **Last seconds.** The gain 900 / remaining turns a few W over the aim into
thousands of W wanted, with the battery far from its rating.
## Decision
The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery
at full power from now on, the quarter still ends more than `ALARM_MISS_W`
(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the
gain. The battery reading it uses is the lower of this cycle's and the last
one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per
quarter, because near the threshold the test flickers with the meter; the
ERROR log line is written for every episode.
**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79
test, check 5), and those misses are real but tiny; the alarm would fire in
the last second of most guarded quarters. The margin (default 50) is the fix
for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity
tariff of the order of EUR 50 per kW a year, 10 W in one month is cents.
Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so
no alarm: the tolerance only matters when the battery is at its rating. A
constant, not an option: `peak_margin_w` is the owner's knob.
**Why the hold (review):** the test assumes the load now lasts the rest of
the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`,
a cycling hob or heater) alarmed at each quarter start while the quarter
landed under the aim. The hold stops that.
**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at
1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the
real cap. While the battery refuses or its reading is lost, the alarm waits
until the quarter is lost even at full battery (27-60 s later in review runs);
those faults have their own alarms. A load over cap + battery for more than
10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0`
removes the hold.
## Not changed: the 1 s spike itself
The cycle after a quarter boundary and before the new quarter's first meter
sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for
one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.
Control is unchanged here: every command in `tools/safety81_sim.py` is
byte-identical to 5a4cdc2 (test_safety81.py part 1).
The double count also moves control, not only the alarm: on a late sample the
guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where
1150 W holds the aim). Left to a follow-up card.
@@ -0,0 +1,353 @@
# TEL-11 — the PV production channel
*What was decided, what it was decided against, and what the evidence was.*
## 0. The problem, in OPT-10's numbers
Over sixteen days at the reference site the pack reached 90 % SoC on **two** of
them, and **20.5 kWh of surplus left the site while the pack still had room** —
**12.6 kWh** of it with the battery **not charging at all**. That single
measurement is upstream of most of what else was found that week: the nightly
floor, SAFETY-37's misdiagnosis, the SAFETY-57 deadlock, a 2 h 47 min saturation
freeze.
It cannot be **attributed**, because **PV production is recorded nowhere in this
system**. Three causes need three different fixes:
| cause | fix |
|---|---|
| there was no irradiance | none — the site behaved correctly |
| production was there, charge power was limited | a limit, somewhere |
| production was there, the pack had room, the optimiser declined | **the only software defect** |
Guessing between them is how an optimiser gets tuned to solve a cloud problem.
**This card adds the channel and does not do the attribution.** Ship it, let it
record a week, OPT-10 picks it up with data.
## 1. It is an HA entity, not a register — and that is a product decision
The RS485 bus this add-on speaks carries the **battery** inverter it drives. On
nearly every site the PV inverter is a different box from a different vendor,
reporting into Home Assistant through its own integration; it is **not** on this
bus and there is no reason it would be. So the signal comes from an entity:
`pv_entity` takes whatever `sensor.*` the site's PV integration provides, any
brand HA can read.
Reading a register would have been cheaper on one house and wrong for a fleet —
the trade [[product-not-one-house]] names.
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
### The convention is derived, not assumed
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
table rather than inferred here:
* `meter_entity` — **positive must mean importing** (`meter_invert` flips it);
* `batt_entity` — **`+` = discharging** (`batt_invert` flips it).
Both are *"positive = power arriving at the house's AC bus"*. Production arrives
there too, so **positive = producing** is the continuation of the shipped
convention rather than a fresh choice.
### Why an invert flag would be over-building
The question is not "could a sensor be reversed" — it could. It is what a flag
would **buy**, and the answer is nothing, for two reasons that do not apply to
the two keys it would sit beside:
1. **Those two quantities are read by the control path; this one is read by
nothing.** `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor (`_read_unit()`'s own docstring says so), so a
reversed battery sensor hands the site a plausible number it then **acts**
on — a latch that trips when it should not, or does not trip when it should.
`pv_w` reaches no decision at all (§4), so a reversed PV sensor cannot
mislead one.
2. **Production is unipolar, so the error is self-announcing.** Battery power
and grid power are legitimately negative half the time, which is exactly why
a flipped sign is invisible in them. A PV array does not consume: a reversed
sensor reads **−4 kW at noon**, in the first day of the very history this card
exists to create. An invert flag would let an installer *hide* that by
flipping it to something that looks right, and a second place for the sign to
be wrong is a second thing to get wrong.
So: the convention is documented in `config.yaml`, both translations, `DOCS.md`
and `app/pv.py`, the value is published **exactly as the sensor reports it**,
and there is no flag. `test_pv.py` part 1 pins `-8 W` through unchanged.
### And no negative-value alarm either
The tempting middle path — warn when the reading is negative — needs a
threshold, because a PV inverter's own standby draw makes a small negative
reading at night entirely ordinary. Any threshold here would be a number nobody
measured, in a card whose whole subject is not fabricating numbers. The
published row is the detector.
## 3. Absence is never zero, and it is not "dropped" either
This is the constraint that shaped the publication, and the obvious
implementation fails it.
`MqttPublisher.publish()` **skips** a `None` value. That is right for
`circulation` — its note in `main.py` says a missing per-unit reading should
leave the last figure standing rather than publish a zero that reads as health —
and it is **exactly wrong here**: a skipped row leaves Home Assistant holding
the last number it was told, *forever*, and the recorder then draws a **flat
line at that value through the outage**. Read back a week later that is
indistinguishable from a real measurement, and if the last value happened to be
small it manufactures precisely the *"there was no surplus to store"* conclusion
the card exists to **test**.
So TEL-11 publishes the absence, using Home Assistant's own mechanism:
> `homeassistant/components/mqtt/const.py`: `PAYLOAD_NONE = "None"`.
> `homeassistant/components/mqtt/sensor.py`, in the state-message handler:
> `if payload == PAYLOAD_NONE: self._attr_native_value = None; return` — and it
> is tested **before** the numeric branch, so it holds for a row carrying
> `device_class: power`.
> (github.com/home-assistant/core, branch `dev`, retrieved 2026-09-24. The
> shipped MQTT-sensor documentation says the same thing in its `state_topic`
> entry: *"a 'None' value will set the sensor to an unknown state"*.)
The entity therefore goes to **`unknown`** and the recorder stores a **gap**.
`mqtt.ABSENT_AS_UNKNOWN` is a one-key set and `test_pv.py` part 3 asserts both
that the pv row gets this treatment **and that `circulation` still does not** —
widening the rule silently would be a behaviour change to another card's row.
⚠️ **What was NOT built, and why.** A per-entity `availability` block
(`availability_mode: all`, an extra retained topic) would also produce a
distinguishable state — `unavailable` rather than `unknown` — but it needs a
second topic, a second retained message, a change to `_payload()`'s
byte-order-sensitive key construction, and it survives restarts only if the
retention is right. The payload route is one branch in `publish()` and one
constant, and it is graded as **bytes on the topic** rather than as a claim
about HA's availability handling.
### Three states, not two
| state | frame | wire | page |
|---|---|---|---|
| no source (`pv_entity` unset) | no `pv` key | no topic, no entity | no row |
| no reading | `pv: None` | `goodwe_ctl/pv` = `None` | `PV —` + a caution naming the reason |
| zero watts | `pv: 0.0` | `goodwe_ctl/pv` = `0.0` | `PV 0 W` |
A failed read also **clears** the last good value (`_read_pv()` assigns on every
call), so a reading never outlives its own sensor. `test_pv.py` part 2 drives a
good read followed by a dead one, because a fixture that answers the same thing
forever cannot reach that failure — MUTATION.md habit 9's shape.
## 4. It must not become a control input, and that is graded twice
### In the suite
`test_pv.py` part 6 drives twelve cycles of the real `Controller.cycle()`
against a 6 kW export and asserts the commands written to the inverter are
**bit-identical** across `unset` / `no reading` / `0 W` / `8 kW` of production,
with a precondition check that the stimulus commands something at all (habit 1).
`inputs_ok` is asserted not to move either: a broken PV integration is **not** a
missing input, because nothing computes with it.
A computed census (habit 12 — the scope is derived from the tree, not typed)
then reports which of the 33 modules under `app/` mention the material in any
spelling, comments included. It is **three**: `pv.py`, `main.py`, `mqtt.py`. The
scanner's own red direction is exercised on every run.
### Across trees, by hash
`tools/safety56_behaviour_trace.py` is the pattern; `tools/tel11_pv_trace.py` is
this card's version of it, and **its control clause had to be different**, which
is worth stating because the difference is the interesting part.
SAFETY-56 changed wording and had to show behaviour did not move, so its
`--compare` **refuses** unless the `sentences` halves **differ** — otherwise the
two runs might be one tree twice. TEL-11's headline claim is the opposite shape:
with the option unset, **nothing** differs. "The documents are identical" is the
*result*, so it cannot also be the control. The two things proved instead are:
* the two runs came from **different trees** (`app/*.py` sha256 + byte count,
compared, and an identity is **refused**);
* **exactly one** side reacts to `pv_entity` — that side is the HEAD, the other
is a tree that does not have the option. Neither, or both, is refused.
It drives 41 stimulus cells (5 grid worlds × 8 PV schedules, plus a run stopped
part-way) and prints two documents per setting: `control` — every command
written, every `self.target`, `inputs_ok`, the loop's three fields, the
arbiter's resolution, the maintenance phase, tick by tick, **with no TEL-11
material in it** — and `telemetry`, which is everything the card is allowed to
move.
Measured, in the CI image, on purged trees:
```
HEAD is B; baseline is A (it does not react to `pv_entity`).
tree A 73296909170ec789237d6e704cce9ab0d9f83b3df1495a14d2497285c3041a12
tree B d5011b6a18e2e3803dc09cac0214b0378fa4c7388fc0ad8ad89b832a5c92af48
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
OK 1/3 UNSET: control AND telemetry are byte-identical to the baseline tree.
OK 2/3 SET: the control document is byte-identical with the channel switched on.
OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES move when the
option is set (42 stimulus cells differ).
```
Note what clause 1 claims and clause 2 does not: **unset** is identical in
`control` *and* `telemetry`, which is stronger than "the control path is
untouched" — an install that does not set the option sees no change of any kind,
including its announcement and its status rows. **Set** is identical in
`control` only, which is the whole of "instrumentation, not an input".
## 5. Genuinely optional — and not a second `price_now_entity`
`price_now_entity` / `price_avg_entity` are legal **only** while
`price_provider` is `off`, and the add-on **refuses to start** otherwise. That
is the trap this card was told not to rebuild, and it is avoided structurally
rather than by intention:
* `validate.py` does not mention `pv_entity` at all. It cannot conflict with
anything, because nothing else reads it.
* the MQTT row lives in `OPTIONAL_SENSORS`, not `SENSORS`, and
`discovery_payloads()`'s new `include` argument **defaults to empty**. So a
caller that has never heard of `pv_entity` — every fixture in `test_mqtt.py`,
`tools/rehearse_roster.py`, anything written next year — announces exactly
what it announced before. `p1_age` solves the same problem the other way
round, with `omit`, because it is announced by default and withdrawn when the
meter is off; getting that direction wrong here would have left most installs
with an entity nothing feeds, which is the hazard `omit`'s own note names.
* the read is gated on the option, so an unset install makes **no state call at
all** — not a read that fails quietly.
* there is **no status row** when it is unset. `peak_forecast_entity` nags when
it is unset because an unset forecast leaves a money guard without an input;
this is instrumentation, and a permanent caution on every install that never
asked for a PV channel is the warning spam the card forbids.
* when it **is** set and the read fails, the row is `ok=False, warn=True` — a
**caution**. `status_payload()` builds `NOT READY` out of rows that are
neither `ok` nor `warn`, so a telemetry channel can never put a battery site
into `NOT READY`.
## 6. The unit is read off the entity, and an unreadable one is refused
`read_inputs()` guesses kW-or-W for `peak_forecast_entity` with
`abs(raw) < 50`, and that is safe there: nobody's quarter-hour demand forecast
is 50 W. It is **not** safe here — 30 W of real production at dusk is an
ordinary reading, and that heuristic would publish it as 30 kW.
So `pv.watts()` reads the entity's own `unit_of_measurement` and converts
against Home Assistant's own `UnitOfPower` table (`mW`, `W`, `kW`, `MW`, `GW`,
`TW`; every factor an exact power of ten). A sensor with **no** unit, or a unit
outside that table, is **refused by name** and the status page says which. The
refusal is loud; a guess would be silent, and a factor of 1000 in the one
channel that exists to be believed a week later is the worse failure.
⚠️ `BTU/h` is in HA's enum and is deliberately **not** in the table: its factor
is a measured conversion rather than a decimal shift, no PV integration
publishes in it, and a fabricated constant is what this repository's whole
process exists to prevent. It lands in the refusal, by name, and `test_pv.py`
part 1 pins that.
## 7. The mutation battery
Every check this card adds was driven by the mutation that defeats the claim it
names. Method: `cp -a` of the tree per mutation into a **unique** directory,
the edit applied there, `__pycache__` purged, the suites run in the CI image
with the tree bind-mounted `:ro`, one file at a time. **The shipped tree is
never edited**, so habit 18's revert hazard — SAFETY-37's harness killed between
the edit and the `cp` that put the file back, and two harnesses sharing one
backup path — is closed by construction rather than by a verified revert. Each
row below records the byte delta, the file's sha256 before and after, and that
the mutated line was **read back off disk** before the run started.
| # | mutation | file | bytes | sha256 (first 16) before → after | red on |
|---|---|---|---|---|---|
| M1 | the `pv` row moved into `SENSORS`, i.e. announced on every install | `mqtt.py` | −79 B | `328d249b9d698ef6` → `532d3954c023bf3d` | 3 named in `test_mqtt.py`, 1 in `test_pv.py` |
| M2 | `publish()` drops a `None` `pv` row again, so HA keeps the last number | `mqtt.py` | −98 B | `328d249b9d698ef6` → `dd54be458f446475` | 1 named (the acceptance criterion itself) |
| M3 | `ABSENT_AS_UNKNOWN` widened past the one key it was written for | `mqtt.py` | +23 B | `328d249b9d698ef6` → `8fe571ebff3dacf4` | 1 named (`circulation` must still be dropped) |
| M4 | a dead sensor reported as **0 W** instead of an absence | `pv.py` | −1 B | `55ba7471e9995436` → `9eecc34152b7ac12` | 4 named |
| M5 | `_read_pv()` assigns only on a good read — a value outlives its sensor | `main.py` | +46 B | `c4d2cab34e50b5fd` → `189802aa69ce166d` | 2 named |
| M6 | the unit is read and then ignored — a kW sensor published as watts | `pv.py` | −18 B | `55ba7471e9995436` → `ab19ef66c52f9379` | 2 named |
| **M7** | **`self.grid = self.grid − self.pv_w` in `read_inputs()` — PV wired into the control path** | `main.py` | +106 B | `c4d2cab34e50b5fd` → `c38216edb7c5c8db` | **2 named, plus `tel11_pv_trace.py` clause 2/3 on 26 of the 41 stimulus cells** |
| M8 | `optional_keys()` returns `("pv",)` unconditionally | `mqtt.py` | −52 B | `328d249b9d698ef6` → `86ff23efe3088ad8` | 3 named |
| M9 | the PV caution made a `bad` row, i.e. a dead sensor is a `NOT READY` | `main.py` | +1 B | `c4d2cab34e50b5fd` → `45c74cab89943564` | 1 named |
| M10 | the read gate removed — an unset option is read once a second forever | `main.py` | −10 B | `c4d2cab34e50b5fd` → `b67baa8aba3019e8` | 2 named, **plus two crashes** |
`on_disk = True` for all ten: the mutated line was grepped back out of the file
after the edit and before the run (MUTATION.md reporting rule 2), and no
mutation is size-preserving, so rule 1's `+0 B` caveat does not arise.
⚠️ **M10's two extra reds are CRASHES, not named checks, and that is recorded
rather than tidied away.** Every `Hass` double in `test_inverter.py` and
`test_telemetry.py` predates this read and has no `state()` method, so an
unconditional PV read is an `AttributeError` rather than a failed assertion —
habit 20's shape, a red with no name attached. Fixing those fixtures would be a
change to what *their* checks report and belongs to whoever next touches them;
this card's own fixture has `state()` and grades the property by name.
⚠️ **M7's trace limb is where the two halves meet.** The mutant moves the
control document on 26 of 41 cells — every cell whose PV schedule produces a
READING. The five `none` cells and the five `zero` cells do **not** move, which
is correct and is worth seeing: subtracting nothing, and subtracting zero,
change nothing. A battery that only drove a dead sensor would have reported this
mutation as a survivor for the third time.
The clean tree those `before` hashes belong to: `app/main.py`
`c4d2cab34e50b5fd`, `app/mqtt.py` `328d249b9d698ef6`, `app/pv.py`
`55ba7471e9995436`.
### ⚠️ M7 was a SURVIVOR on the first battery, and the fixture was the reason
The card's **central** mutation — `self.grid = self.grid - self.pv_w` in
`read_inputs()`, i.e. exactly the defect the whole card forbids — came back
**green** on a six-file run at **+106 B**, on disk, purged, genuinely running.
The property did not hold; the fixture could not reach it. `test_pv.py` part 6
drove a **6 kW export over 12 cycles**, which puts the loop **against its
rail**: the command moves `slew_w` every cycle and −6000 W and −14 000 W of
apparent error produce the **same ramp, tick for tick**. MUTATION.md habit 23,
with a saturated actuator in place of a coinciding value — and the habit's list
of no-op-prone constructs is all *expressions*, so this is worth its own entry,
which it now has.
The fixture now drives a **600 W import the loop settles on**, with a
responsive meter (`grid = base − batt`) so the error actually closes, asserts
the resting target is well inside the rail, and asks habit 23's own question as
its own check: **shift the meter by the same 8 kW and confirm the commands
really would have moved.** On the first fixture that question answers *no*, in
one line. Every number in the table above is from the battery re-run against
the fixed tree.
⚠️ **A red count is a property of the suite as much as of the mutation.** These
name **this** tree at this commit; a later card that adds or re-bases a check
moves the number with the mutation untouched. Re-measure rather than inherit.
## 8. What none of this covers, with shapes
* **Home Assistant itself.** That `None` renders as `unknown` and that the
recorder keeps a gap is read out of HA's source and quoted in §3; no HA runs
in this suite. What is graded here is the bytes this add-on publishes.
* **Whether the operator's sensor tells the truth.** A sensor wired backwards,
or reporting DC where AC was meant, or a string instead of the array, is
published faithfully. §2 is the argument for why that is the right failure
mode; it is not a claim that it cannot happen.
* **A roster of more than one.** The row is a **site** row — there is one PV
array as far as this add-on is concerned — so nothing per-unit exists to
separate, and every fixture is the shipped roster of one.
* **The attribution.** This card records production. Whether the 20.5 kWh was
cloud, a limit or the optimiser is OPT-10's, and it needs a week of this
channel's history before it can be asked.
* **`tools/`, the firmware and the ingress HTML** are outside part 6's census.
* **The identity is only as wide as the stimulus.** `tel11_pv_trace.py` prints
its 41 cells with the documents for exactly that reason.
## 9. No `CHANGELOG.md` entry
`test_version_gate.py` 11b refuses a newest `## ` heading that is not
`config.yaml`'s `version:`, and opening an `## Unreleased` section would mean
taking a packaging decision this card does not get to take. The operator account
is in `DOCS.md`; the release note belongs to the next PKG ticket, which should
say that **0.10.1 → next** adds one optional key, announces no new entity on an
install that does not set it, and changes no control behaviour.
@@ -0,0 +1,361 @@
# TEL-12 — several PV inverters, and why a partial total is worse than none
TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. The owner of the
reference site reported **within minutes of it going live** that they have
several PV inverters and could configure one.
That is not a property of one house. A multi-string or multi-orientation roof
routinely carries two or three inverters, each publishing its own power sensor
into Home Assistant, and the add-on is sold to clients. So the channel had to
become a roster.
**OPT-10 is blocked on this and cannot start recording until it lands.** Which
sets the bar: the number this channel publishes has to be one an attribution can
believe, or the week of history OPT-10 is waiting for is worse than no history.
---
## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that
decided it
Two candidates, and they differ on exactly one question nobody in this
repository could answer by reading: **does Supervisor accept a stored STRING for
a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the
live install *has* a string persisted in it; and an upgrade that refuses that
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
direction, with the schema needed to fix it pinned to the version that cannot be
installed.
It was measured, against the release image, by a new **§G** of
`tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this
kind of question:
```
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
```
```
== G. TEL-12: the SHAPE of a several-inverter PV option ==
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
pv_entity='sensor.pv_power' pv_entities=[]
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
a list value validates
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
== all cases passed ==
```
| Claim | Evidence |
|---|---|
| **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored |
| …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway |
| …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this |
| **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 |
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
| **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` |
| `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key |
| A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing |
`p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped
precedent for the pair; nothing new was invented.
**So the surface is uglier than option 1 and that is the correct trade.** The
cleanest surface that breaks the one install this card exists to fix is not the
cleanest surface.
### And `pv_entities` REPLACES `pv_entity` rather than extending it
CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied
to the only upgrade path that matters. The install being upgraded **has**
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural action
is to put *all* the inverters in the new repeatable field — and under a union
rule that **double-counts A** unless the operator also remembers to clear the old
key. A doubled total is the same defect as a partial one pointing the other way,
in the one channel that exists to be believed a week later.
Under replacement the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about. It
de-duplicates *within* the list (a site cannot have two sensors with one
entity_id, so a repeated id is a typo that would double that inverter), and
`Controller.checks()` prints a **caution** whenever both keys are set saying
which one won — a replacement nobody is told about is the "one of which silently
wins" `config.yaml` refuses elsewhere.
⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not
mention either key. A telemetry channel that could stop a battery site from
starting would be observability with a foot in the control path.
---
## 2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
Three inverters configured, one `unavailable`. The sum of the other two is **a
smaller number indistinguishable from a real one.** Fed into OPT-10's
attribution it reads as *"there was less PV than there was"* — which is the "no
irradiance" conclusion the channel exists to **test** rather than to manufacture.
Two designs were genuinely available:
**(a) publish the total as absent** — `pv.total()` answers `None`, the topic
carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap.
**(b) publish the partial sum alongside a count of how many contributed.**
**(a) shipped.** It wins on the one question that matters, which is *who reads
the number and when*:
* The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything
that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a
smaller number indistinguishable from a real one, and the count that would
have qualified it lives in a **second entity with its own history** — so
believing the total requires joining two recorder series by timestamp to find
out which samples to distrust. Nobody re-derives that. And it is worse than a
gap in exactly the direction that matters: **a gap makes the attribution say
"I do not know"; a partial makes it say something false.**
* The **status page** is read **live**, by a human who wants the diagnostic. So
the partial information is *not* thrown away — the row names every entity that
did report, with its watts, and every one that did not, with its reason, and
states plainly that there is no site total. Different reader, different
instant, different rule.
A count published beside the total would also carry **no information** in the
state (a) admits: the total is published *only* when every entity contributed, so
the count would be the constant `len(roster)`.
And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s
`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is
untouched: the line that turns an absence into HA's `unknown` did not have to
change, because "absence is never zero" already covered this one layer up.
### The mirror, which matters just as much
A member honestly reporting **0 W** is **summed**, not treated as absent. The
test is `watts is None`, never falsiness. Shading, a tripped string and
nightfall are all real zeros, and a roster that blanked the site whenever one
member read zero would report an absence **on every clear evening** and defeat
the channel from the other end.
### ⚠️ And the answer is NOT "document a template sensor that adds them up"
An operator may legitimately prefer one, and `pv_entity` will happily point at
it. Shipping it as *the* answer pushes the absence problem onto them unseen: a
template sensor summing an `unavailable` source silently yields the partial
total — the same defect one layer down, in a place this add-on cannot see or
report. DOCS.md says so in those terms.
### The three published states, side by side
| what happened | frame | `goodwe_ctl/pv` | status page |
|---|---|---|---|
| no roster at all | no `pv` key | no topic, no entity | no row |
| 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three |
| all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported |
| all three gone | `pv: None` | as above | as above |
---
## 3. Still not a control input
Unchanged, and re-proved at the new roster size.
`tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name
still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and
`test_pv.py`'s mutation table quote it, and a renamed instrument makes both
references dangle for no gain.
It now produces **three** document sets per tree — `unset`, `one`, `multi` — and
grades five claims. The roster cells live in their own document precisely so
`unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to
an existing document would have moved a hash whose stability is itself the
evidence.
```
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
$ echo $?
0
```
**The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both
trees.** The roster control document `2aafbfedfb9c…` is byte-identical between
the two trees, which is the strongest available form of the claim — the baseline
runs those cells with a key it has never heard of, so its control document for
them *is* 0.11.0's.
⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
exactly as 0.11.0 does" is a real cross-tree identity over **both** documents.
`pv.total()` passes a single entity's `(watts, why)` through verbatim for exactly
this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a
`len(...) == 1` branch rather than generalising them.
### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding
TEL-11's rule was *"exactly ONE side's `set` document may differ from its own
`unset` — that side is the HEAD"*. It was correct evidence when written, because
`release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both**
sides react to `pv_entity`, and that refusal fires on **every honest run**. The
discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so
only the HEAD's `multi` document fails to reduce to its own `unset` one.
It is asserted from the **documents**, not from `tree.supports_roster` — a
self-report is the tree describing itself, whereas "this side's behaviour did not
move when `pv_entities` was set" is the tree being measured. `supports_roster` is
printed beside the verdict so a disagreement between the two is visible.
See MUTATION.md habit 24.
---
## 4. Mutation battery
Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never
reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run
and the tree checked for one afterwards, byte delta from `stat` and the edit
confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own
command one file at a time under a `:ro` mount.
| mutation | bytes | reds |
|---|---|---|
| N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash |
| **N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** |
| N2 absence tested by falsiness (`not w`) | −4 B | 2 named |
| N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named |
| N4 the de-duplication dropped | −15 B | 1 named |
| N5 `sum` → `max` over the roster | **+0 B** | 6 named |
| N6 the roster-of-one stops passing `why` through | +13 B | 8 named |
| N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash |
| N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
| N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named |
| N10 `entity_is_ignored()` always False | +10 B | 2 named |
| N11 `pv_entities: []` deleted from `options:` | −18 B | 2 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe |
**No survivors.**
⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three
characters, so neither the file size nor its mtime moved, and TEST-15's
stale-bytecode defect is exactly what manufactures a survivor there. The run was
on a fresh purged copy and `cmp` confirmed the edit on disk before it started.
⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other*
defensible design, implemented and run. The point is that the checks and the
instrument can **tell the two apart** — an unexamined partial sum is what the
card forbids, so a check that could not see one would be decoration:
```
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
real one, which is the defect this card exists to prevent.
```
⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss.** With no PV
entity configured `pv_w` is `None` and the leak cannot fire — which is what makes
1/5 a claim about the **default** and 2/5–4/5 the claims about the channel.
### The fixture values are the design (habit 10 and habit 23)
`test_pv.py` carries **two** rosters over the same three entities, because the
two claims need different magnitudes and one fixture cannot serve both.
* **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from
max (2100), min (450), first (1200), last (450), mean (1250), count (3) and
every partial of itself (2550 / 1650 / 3300) — the family is computed, printed
and asserted non-colliding as its own precondition. One member is in **kW** so
"correct against their own units" is exercised. **Not one member is zero**: a
member contributing 0 W could go absent without moving the sum of the rest, and
a fixture like that cannot tell "published the absence" from "published the
partial sum" — habit 23 with a coinciding value in the *fixture* rather than in
the plant, which is precisely the trap the brief named. Absence is driven at
**every position** of the roster, not at one (habit 10's fixture-as-a-SET), and
each position's partial is **derived from the fixture** rather than typed
beside it.
* **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial*
(6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import.
Habit 23's own question is asked twice — shift the meter by 8000 W, and again
by 3500 W, the smallest partial any single dead member could produce — because
a leak of the partial is a different mutation leaking a smaller number.
### Two false results this card produced, and what they were
Both were in the **instrument**, and both are recorded rather than tidied away.
1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell.
They are a fact about the *stimulus*, produced by the tool and identical on
both trees — so every roster cell differed from its unset reference for a
reason that was not behaviour, and the HEAD/baseline discriminator fired on
*both* sides. They live in a separate top-level `partials` map now.
2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its
**true total** labelled as its "partial". 5/5 then refused the *shipped* tree
for publishing 1650 W, which it is required to publish. A cell with no dead
member has no partial to leak, and `never` is `None` there.
---
## 5. What none of this can see
* **Home Assistant itself.** That `None` on the state topic renders as state
`unknown` and that the recorder keeps a gap is read out of HA's source and
quoted in `app/mqtt.py`; whether the long-term statistics table then
interpolates across the gap is HA's behaviour, and OPT-10 will be reading it.
* **Whether the operator's sensors are telling the truth.** A sensor wired
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
`pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster.
* **A roster larger than three.** Three is the smallest size at which the sum
separates from the reduction family *and* absence can be driven at every
position. Nothing here says what twenty entities do to a cycle's latency — the
read is `asyncio.gather`ed across the roster for exactly that reason, so a slow
sensor costs what one costs, but that is an argument rather than a measurement.
* **A PV roster combined with a two-inverter battery roster.** No fixture
crosses CFG-04's `inverters:` with `pv_entities`.
* **The real MQTT transport, and wall-clock behaviour.** As TEL-11.
---
## 6. Not deployable as merged
`config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP
until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately
not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and
the bump is PKG's call. The material an entry needs is this file.
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
empty on every install that does not set it.
**Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.
@@ -0,0 +1,83 @@
# TEL-14 — the PV sign, declared per entity
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
**positive for consumption, negative for production**. At night the page said
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
## 1. This reverses TEL-11 §2, and why
TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the
control path" still holds: the flip lives in `pv.total()`, and `test_pv.py`
part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor
announces itself" did not survive a real site: these sensors follow a different
convention, read a plausible small positive number at night, and the only other
fix is a template sensor, the place TEL-12 §2 says goes quietly wrong.
## 2. The shape: a parallel list, `pv_invert_entities`
Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against
a 0.12.0 blob); re-run it for the transcript.
| shape | schema | why it lost / won |
|---|---|---|
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface |
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
## 3. The negative caution: per entity, below −50 W
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
about −13 W then. −50 W clears that with room for a few such devices behind one
entity; a reversed sensor by day reads hundreds to thousands of watts below
zero. It is tested **per entity, not on the total** (a deviation from the brief):
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
a wrong threshold costs a spurious or missed caution, never data. The text says
ADD (unflipped) or REMOVE (already flipped).
## 4. Mutations
Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`;
`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's,
red since B1 (merged into release/1.0 @ 43b2740).
| # | mutation | bytes | reds |
|---|---|---|---|
| T1 | flip ignored in `total()` | −45 | 9 |
| T2 | flip applied to every member | −7 | 6 |
| T3 | `0.0 - w` → `-w` | −5 | 1 |
| T4 | flip written as `abs()` | −1 | 2 |
| T5 | `_read_pv()` stops passing the flip | −26 | 7 |
| T6 | caution threshold −50 → 0 W | −17 | 3 |
| T7 | `[sign inverted]` marker dropped | −39 | 3 |
| T8 | negative caution made `bad` | +1 | 1 |
| T9 | `stray_inverts()` always empty | −50 | 1 |
| T10 | `inverted()` stops stripping | −8 | 1 |
| T11 | ADD/REMOVE advice swapped | +4 | 2 |
| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe |
| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) |
| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) |
## 5. History
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
such sensors carries the old sign; nothing rewrites it.
## 6. CHANGELOG entry for the next PKG cut
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
> producing — typically a kWh meter or power clamp in the PV circuit — can now
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
> its sign is flipped before the sum and the status page marks it. Empty (the
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
> old sign.
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
the reason it stays, and the reason the panel refuses to write a blob the
validator rejected: this surface must never be the thing that removes itself.
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
keeps the panel: options that name no inverter at all and have no other
refusal, which is what a new install ships. The add-on then serves the page in
setup-only mode and commands nothing. Every other refusal still exits 78.
**The panel shows fewer refusals than the startup gate can, never more.** Two
of `validate.py`'s rules compare a configured ceiling against the setpoint
device's *own* reported range, which is only knowable by asking Home Assistant.
@@ -0,0 +1,92 @@
# 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
1. **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.
2. **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.py` and `test_watchdog.py` now pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none.
3. **The over-rating alarm keeps its numbers** (asked, sent, and which limit cut it), because they are what a fault report needs. `ClampWatchdog.facts` carries them under the same keep-the-bug rule as `reason`, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40, `test_clamp.py` part 6).
4. **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.
5. **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.
6. **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.
7. **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 `#heading` anchor 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
1. **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.
2. **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".
3. **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. A `tools/tel11_pv_trace.py --compare` against 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.
4. **UX-07 item 1.** `ClampWatchdog` now also counts `over_rating` and `bound` separately. 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.
5. **The "Not ready" banner** is the first red row's own `banner`, or else its first sentence. The peak-reserve and over-rating rows set `banner` to 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.").
6. **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.
7. **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.
8. **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
1. **The Details link follows the Home Assistant version.** HA 2026.2 added the built-in `app` panel (core #157554, frontend #28245), whose add-on pages are `/config/app/<slug>/documentation`. 2026.5 stopped registering the `hassio` panel, so `/hassio/addon/<slug>/documentation` is 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.
2. **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.
3. **"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.
4. **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".
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks 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 from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
6. **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.
1. **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, because `configui.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 as `price_markup_eur_kwh` in 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 beside `inverters`, where that value is changed, and not only beside the flat `max_w` that a list does not read.
2. **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.py` now checks the tab for that instruction and DOCS for the record.
3. **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.
4. **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.py` and `main.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.
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. 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. **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".
9. **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_pct` or "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*, not `None`.
10. **The panel's key match** no longer files `inverters.max_w` under the flat `max_w`.
11. **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).
12. **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).
1. **Why the 2d scan missed the clamp lines.** Part 3 reads a log call's own literal and counts `%s` as one word. The clamp lines are `"…software fault: %s …"`, and the text in `%s` was built elsewhere, by `loop_clamp_note()` and `ClampResult.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.
2. **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.
3. **Codes are one token.** `PEAK GUARD`, `PEAK GUARD FLOOR-LIMITED` and `PEAK GUARD BLIND` read as capitals, so they are now `PEAK_GUARD`, `PEAK_GUARD_FLOOR_LIMITED` and `PEAK_GUARD_BLIND`, like `OUTPUT_CLAMP`. Nothing in admin/goodwe or in the scripts under `/home/homelab` greps them (checked). `/home/homelab/clampwatch.sh` greps `OUTPUT_CLAMP`, which stays.
4. **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_carried` counts the provisional points), so the estimated count already says it.
5. **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.Outage` says 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".
6. **Display bugs.**
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.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.stale` is 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. `stale` itself, 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.
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
9. **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.
# UX-11: safety log lines give the code, the figures go to DEBUG
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
@@ -0,0 +1,104 @@
# UX-13 — one layout table, and why UI code may now name an option key
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
## 1. What UX-02 said, and why it no longer fits
UX-02 built the configuration panel from the Supervisor's schema, so
`app/configui.py` contains "no option key, no label and no rule", and
test_selfconfig.py part 5 checks that the page script names no key. That kept
a second copy of the option surface from drifting, and it is why the panel is
a flat list of fields in schema order.
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
for a value stored in W, a picker for an entity, a field shown only for one
meter source, locked measured values, a Simple view and a setup wizard. It
also needs page fields that are not options at all: one inverter card that is
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
a contract and two prices that become `static_profile` JSON, and a region
that becomes the levies figure. None of that can be read off the schema, so
some code has to name option keys.
## 2. The decision
Option keys may appear in UI code in exactly two files:
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
control, unit, display scale, step, entity kind, when it is shown, whether it
is on the Simple view or a wizard step, and whether it applies now or after a
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
exactly once.
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
`ui_to_options()`. It may name only the keys it writes.
What UX-02 made authoritative stays authoritative:
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
none. A unit holds no digit.
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
test_messages.py.
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
"warn" or "info") for the page. A note never blocks a save, and passing the
notes list does not change the refusals returned.
- **The stored blob** is still the one truth. The page model carries the stored
values of every derived key (`keep`). A derivation whose page fields are
unchanged writes those values back untouched, so a blob the page cannot
express survives a save of another field.
## 3. How it is held
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
field, that the table does not place. It also checks that uimodel.py names
only the keys it writes and that web.py and haentities.py name none.
- test_uimodel.py round-trips options → page model → JSON → options for every
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
option files and config.yaml's defaults. All must be identical.
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
new validator. At P0: 261 calls, 158 refused, no refusal changed.
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
into `app/static/` and the same check applies there.
- P2: the Installer view (`app/static/settings.js`) draws from
`app/uipage.py`, which serialises the table and names no key itself
(test_uilayout part 5, test_settings part 2). The view posts its page model
to the existing `/config/validate` and `/config/save`, which turn it into
options with `ui_to_options()` before the shipped validator runs. No route
was added.
- P4: the setup wizard draws the same model by each field's `wizard` step.
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
names no key. See [[UX-13-setup-wizard]].
- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set,
loaded by `app/uitext.py` with an English fallback per key. The page picks
Dutch when the browser's language is `nl`, as the classic page does. Home's
sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl`
builds them in Dutch. Status rows, banners, hints and validator sentences
(`messages.py`, `validate.py`) stay English: they are shared with
notifications, the log and the start refusal. `test_ui_text.py` checks both
files against STYLE.md and each other, using the rules in
`tools/style_rules.py`, which it shares with test_messages.py.
- P5: "Other time windows" is a list editor (`ui.periods`, control
`period_list`). `uimodel._periods_json()` builds `static_profile` and names
the first bad time, empty price, overlap or gap. Its tiling rule matches
`static_tariff._check_tiling`. A stored profile whose days the editor has no
choice for (for example "mon,wed") is still kept as stored and edited in the
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
wizard blobs, 17 of them with time windows
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
- P3: Settings, Simple draws the fields the table marks `simple`, with an
optional `note` under each (words in `settings.simple.note`), plus the
batteries' switches and modes. Those are store state set through the
existing `/action` values, not options, so the table places no key for
them. The Batteries screen's Now, Today and Why lines are built in
`homeview.py` from what the add-on knows this cycle; the plan per mode is
OPT-11 P3/P4, so until then Today says "Not planned yet" when no block
is planned for the battery (`test_batteries_screen.py`).
## 4. What this costs
The table is a second place that knows the option surface, which UX-02 set
out to avoid. The cost is bounded by the coverage test: an option added to
config.yaml and not placed here fails CI, rather than silently missing from
the page. Changing a key's type or range needs no change here.
@@ -0,0 +1,101 @@
# UX-13 P4 — the setup wizard, and when it opens by itself
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
## 1. What the wizard is
The Setup tab draws the Installer view's page model one step at a time: a
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
controls, `/config/validate` and `/config/save`. No route was added.
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
yet (OPT-11); it says they come later.
A step moves on when no refusal is anchored to one of its shown fields
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
no field at all blocks the last step, which lists every refusal.
## 2. When it opens by itself (`uipage.setup_needed`)
The page opens `#setup` only when it is opened with no route, and only when
`/config/model` gives a reason:
- **`no_power_request`**: the stored options name no inverter to control.
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
Store has no `setup_done` and no `auto` key, and control is not started.
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
setup-only mode (UX-15) writes the Store, so a new install's first good boot
is fresh.
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
R1). So a Store read from a file is an old site, and it is never sent to the
wizard. A new install that restarts before the page is opened is not sent
either, and its Setup tab still works. A site that runs control, or ever
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
`setup_done` (an ISO time) to the Store. A save from the Installer view does
not.
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
naming no inverter is the gate's only refusal, the add-on serves the page in
setup-only mode, which commands nothing, and the wizard opens with
`no_power_request`. Setup-only mode writes no Store file, so the first-run
rule above still holds for an install set up on the Configuration tab.
## 3. The kettle test and the sign checks
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
`hass.state()` read, in W) before and after a kettle is switched on. The
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
off, a fall turns it on. For a net entity or import and export, a fall cannot
be fixed by an option, so the step is not passed and the page says what to
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
the page, so the test is skipped and Home shows the direction after the
restart. The installer may skip the test, because a cloud can fake a fall;
the last step then warns, and after a fall it says the reading went down.
The battery and solar signs cannot be forced from the page, which never
commands an inverter. The last step reports each battery's reading with its
direction ("reads 800 W charging now, so check the inverter shows the same")
and warns when a solar entity reads below zero without its "reads negative"
switch. It also warns when meter + solar + battery, all known, add up to a
house using less than -300 W: a battery sensor reversed while giving power.
An idle or charging battery cannot be judged that way.
## 4. The last step
`validate.setup_checks()` sits beside the rules because it names option keys.
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
5 s old, the running options, the running meter reading and today's price
count. Tiers are ok, info, warn and bad. A power request entity missing from
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
and only the entity list can tell. So is Home Assistant not answering, since
the list is then unknown. The wizard does not save while anything is
bad. It then saves through P2's path (`setup: "done"`) and asks for the
restart as the second, confirmed request, with a dialog that names the 0 W.
The shadow switch is on by default and writes `shadow_mode: on`, which holds
every inverter at 0 W after a restart even if control is started. Off keeps the
stored value, or `auto` if that was `on`. The switch goes only on the model the
wizard posts, never into the page model the Installer view also saves.
## 5. The stale save lists only what moved (P2 test, finding a)
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
stale save the add-on compares that blob with what is stored now, so the banner
lists only the other surface's changes. `changed` (stored against the posted
blob) is unchanged for the classic page's "theirs → mine" list. When the
fingerprint was not handed out by this process, it falls back to `changed`.
## 6. How it is held
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
it), step blocking, the first-run rule on new and TEST-23 sites, the live
checks, the save through the P2 path, the stale fix, and the words.
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
install and three TEST-23 sites (88 accepted by the shipped validator).
UX-15 added the shipped defaults as a start (setup-only mode). At P4
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
keys each.
@@ -0,0 +1,114 @@
# UX-15 — setup-only mode: a new install serves the wizard
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
## 1. The rule
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
refused by rule 1 of `validate_startup_config()`: no inverter to control.
Until now that was exit 78 before any page was served, and the installer had
to fill in the entity on the Configuration tab before the wizard could open.
The add-on now starts in **setup-only mode** when, and only when
(`setuponly.applies()`):
- the options name no inverter at all: no flat `setpoint_entity` and no
`inverters` entry, and
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
refusal.
In setup-only mode it serves the page, says it is in setup (banner, `/status`
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
opened with no route (`setup_needed` is `no_power_request`). After the
wizard's save and restart the normal gate applies to the saved options.
## 2. What still exits 78, and why
Everything else the gate refuses, including:
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
`max_w`;
- no power request entity **and** any other refusal;
- an `inverters` list whose entry has a blank power request entity.
These are configurations somebody edited. The Configuration tab stored them,
the log's refusal banner names each problem, and the tab is where they are
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
front of someone who needs to change one field. A narrow rule also keeps the
setup-only decision to one comparison against one known sentence, so a new
validator rule cannot widen it by accident: any other refusal fails the
equality and exits 78.
The cost: an installer who first sets, say, a DSMR meter with no entities on
the Configuration tab and leaves the power request entity empty gets exit 78,
not the wizard. The log then names both fields.
## 3. Why it cannot command
Setup-only mode builds nothing that commands. `amain()` branches to
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
the tariff task are built, and it returns from there when the Supervisor
stops it. What serves the page is `SetupOnly`, which holds no inverter.
Three checks make it structural rather than a convention:
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
`call()` refuses before it sends anything, and every service call goes
through it: `number.set_value` (`set_number()`), notifications, all of
them. The flag is fixed at construction, and there is no setter.
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
refuses one that cannot.** So neither can be built on the wrong one.
3. **`/action` answers 409** in setup-only mode, before
`handle_action()`. Start, Stop and the battery cycle do nothing.
No MQTT client is created, so nothing is published. The page's other routes
already read only, or write the add-on's own options (UX-02 §6).
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
as in any mode. So a new install that is set up on the Configuration tab
instead still has a fresh Store on its first normal boot, and the first-run
rule (UX-13 §2) sends it to the wizard as before.
## 4. How it is held
- `test_setup_only.py` part 1: when it applies, including the three exit-78
cases above. Part 2: the read-only write path refuses with nothing sent,
and the two constructors refuse. Part 3 boots the real `main()` on the
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
logs every request: the page is served, `/status` says setup-only, Start
and the cycle get 409, and there are 0 service calls and no MQTT request,
also with `auto_start` on and a Store that says control was started. The
wizard's save is then booted: it passes the normal gate, does not open the
wizard again, and every write is 0 W (shadow). Part 4: the refused
configurations exit 78 and serve nothing.
- Mutations, each red: `applies()` always false or always true, the
read-only refusal removed, the Controller's check removed, `amain()` giving
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
`return` after `serve()` removed.
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
defaults and an empty `/data` made 0 service calls and no MQTT request in
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
and after the restart it ran in shadow: 11 of 11 writes were
`set_value 0.0`.
## 5. The wizard fixes in the same card
- **F1.** `/config/validate` converts what it can of a page model
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
step beside its field and the live checks still run on the rest. A refusal
about a key the failed conversion writes is left out, because it would
judge the stored value. The save still refuses such a model (422, with
`steps`).
- **F2.** A save from the page model sends `base`, the model it opened. The
add-on writes only the keys whose value differs from `base`, onto the blob
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
409 keeps the other surface's changes. A key both surfaces changed takes
this page's value, which is what the second press after the banner means.
- **Meter entity.** On the last step a meter entity Home Assistant does not
have is red, like a missing power request entity, so a skipped kettle test
cannot let it through. In the Installer view it stays a warning.
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
+928 -89
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -852,6 +852,136 @@ Cheap habits fall out of it, and all of them are now used in this repository:
invented justification for a correct decision is still a defect, and it is
the kind a reviewer cannot see because the code is right.
23. ⚠️ **THE MUTATION IS SUBJECT TO EVERY HABIT ABOVE, HABIT 4 INCLUDED - AND
THE PURGE, THE FINGERPRINT AND THE BYTE DELTA ALL SAY IT TOOK.** SAFETY-57's
battery reported a **survivor at +21 B** for a mutation of
`Freshness._open_window()`: `self.graded_from = float(now_mono)` rewritten
as `float(self.changed_mono or now_mono)`, which is meant to re-anchor the
verdict's window to the register's own history and so delete the card's
whole exit. The harness purged, the tree fingerprint showed the edit on
disk, the delta was non-zero, and the mutant really did run. It was still a
survivor for a reason that has nothing to do with the property: in the
fixture the register never moves after `t = 0`, so `changed_mono` is
**`0.0`** - and `0.0 or x` is `x`. **The mutant was a no-op on that
stimulus.** Rewritten with an explicit `is None` test (+82 B) it is red on
four named checks.
Stale bytecode is a mutation that never *ran*. This is a mutation that ran
and could not *change the answer* on the data it was given, which is habit 4
exactly, one level up - and none of the three things a mutation report is
required to state (MUTATION.md's own list) can see it, because all three are
about the file rather than about the stimulus. Two rules:
* **Ask of every survivor what it would have had to change, and check that
the fixture reaches that value.** Here: "does any fixture have a non-zero
`changed_mono` at the moment `_open_window()` is called?" The answer was
no, and it is one line to ask.
* **Prefer a mutation that CANNOT be a no-op.** `or`, `and`, truthiness on
a numeric, a default argument and a `getattr` fallback all have a value at
which the mutant and the shipped tree coincide. An explicit comparison,
a constant swap or a deleted statement does not.
⚠️ Note where this one was found: in the battery of a card whose whole
subject is a check that cannot reach the failure it names. **Second
instance in an instrument rather than in a check since habit 16, and the
third overall.**
⚠️ **AND THE SAME CARD'S BATTERY CAUGHT A HABIT-22 COMMENT IN THE FIX
ITSELF, WHICH IS WHY THESE TWO BELONG NEXT TO EACH OTHER.**
`freshness.py` shipped `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS
LOAD-BEARING` with a worked reason. Swapping the two calls is **zero bytes**
and survived the battery: `_grade()` acts only when `|standing| >= move_w`
and `_release()` only when `|standing| < move_w`, so at most one can act on
any read and the order is **not observable**. Habit 22 says build X and
measure it; the battery is what builds X. The comment now states the
disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and
1300 W - rather than an order nothing depends on.
⚠️ **SECOND INSTANCE, TEL-11, AND THE NO-OP WAS A SATURATED RAIL RATHER
THAN A VALUE.** The card's whole subject is that a PV reading must never
reach the control law, so its central mutation is
`self.grid = self.grid - self.pv_w` in `read_inputs()` - **+106 B**, on
disk, purged, genuinely running - and it came back a **SURVIVOR** of a
six-file battery. The property held in no sense: the fixture drove a 6 kW
export over 12 cycles, which puts the loop **against its rail**, and a
command moving `slew_w` every cycle produces the SAME ramp for -6000 W and
for -14000 W of apparent error. The mutant ran and could not change the
answer.
Two things generalise past PV:
* **A SATURATED ACTUATOR IS A VALUE AT WHICH EVERY INPUT COINCIDES.** The
habit-23 list above names `or`, `and`, truthiness, default arguments and
`getattr` fallbacks - all *expressions* with a coinciding value. A rail,
a clamp, a slew limit, a deadband and a latched alarm are the same hazard
in the **plant**: they are where a controller's output stops being a
function of its input. A fixture that parks there cannot separate any two
inputs, and this repository's control fixtures park there by default,
because the dramatic stimulus is the saturating one.
* **SO THE PRECONDITION IS NOT "DOES IT COMMAND SOMETHING", IT IS "DOES IT
REST SOMEWHERE THE MUTATION COULD MOVE IT FROM".** `test_pv.py` part 6
now drives a 600 W import the loop SETTLES on, asserts the resting target
is well inside the rail, and asks habit 23's own question as a check:
shift the METER by the same 8 kW and confirm the commands really would
have moved. On the first fixture that question answers NO, in one line,
and it is the line that turns the identity into evidence.
24. **A REFUSAL DEFINED RELATIVE TO "THE BASELINE LACKS THIS FEATURE" DIES ON
THE RELEASE THAT SHIPS THE FEATURE — AND IT DIES AS A FALSE REFUSAL, WHICH
IS THE LOUD DIRECTION.** TEL-11 built `tools/tel11_pv_trace.py` to grade a
cross-tree identity, and because "the documents are identical" was its
*result* rather than its warning sign, it had to prove instead that the two
runs came from different trees and that **exactly one side reacts to
`pv_entity`**. That was correct evidence: `release/1.0` predated the option
entirely, so a side that reacted to it was the HEAD by construction.
TEL-11 then **merged**, `release/1.0` became 0.11.0, and the baseline for
the next card reacts to `pv_entity` too. `--compare` now answers
`REFUSED: set differs from unset on ['A', 'B']` on every honest run — the
instrument refuses its own shipped tree. TEL-12 found it on the first
comparison it ran.
* **A discriminator is a fact about a MOMENT, not about a card.** "The
baseline does not have X" is true until X ships and then false forever.
Write the discriminator against something the NEXT baseline still will not
have — TEL-12's is the roster key, not the channel — and say in the tool
which fact it rests on, so the next card can see what it has to replace.
* **Prefer a discriminator that is MEASURED over one the tree REPORTS.**
`tree.supports_roster` is the tree describing itself and would have been
one line; the shipped test is "this side's roster cells ARE its unset
cells", which is the tree being measured. Both are printed, side by side,
so a disagreement between the self-report and the measurement is visible
rather than silently trusted.
* ⚠️ **And this is the SAFE direction of the same defect, which is why it
was found in ten minutes rather than in a review.** A stale refusal is a
developer chasing a failure they cannot explain (the false-red case at the
top of this file). A stale *identity* — an instrument that goes on printing
OK because its reference drifted into agreement — is the survivor-
manufacturing direction, and nothing announces it. Ask of every cross-tree
instrument: *what does this print when the feature it is about has been in
the baseline for a year?*
TEL-12's own battery produced two more instrument defects of the ordinary
kind, both recorded in `DECISIONS/TEL-12-several-pv-inverters.md` §4: a
stimulus annotation written into the document being compared (so every cell
differed for a reason that was not behaviour, and the discriminator fired on
both sides), and a fixture cell whose TRUE total was labelled as its
"partial" (so the check refused the shipped tree for publishing a number it
is required to publish). ⚠️ **Both were caught by running the instrument
against the shipped tree BEFORE running it against a mutant** — which is the
one ordering rule that separates "the instrument is wrong" from "the tree is
wrong", and `test_mqtt.py` part 8's first line ("the shipped module satisfies
every property this instrument grades") is the same rule as a check.
⚠️ **AND TEL-12's SUBJECT WAS HABIT 23 IN THE FIXTURE ITSELF.** The card adds
a SUM over a roster, and the defect it exists to prevent is publishing the
partial sum when a member is absent. A fixture whose absent member
contributes **0 W** cannot see that defect at all: the partial and the full
total are **the same number**, so "published the absence" and "published the
partial" produce identical output. That is a rail in the *arithmetic* rather
than in the plant — the same hazard as TEL-11's saturated actuator, one
domain over. `test_pv.py`'s roster is three distinct non-zero contributions
in two different units, absence is driven at **every position** rather than
at one (habit 10), and each position's partial is **computed from the
fixture** and asserted to differ from the true total as its own precondition.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+117 -8
View File
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
DROP_CONTRADICTION = "contradicts itself"
DROP_NO_INTEGER = "no integer setpoint in its interval"
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
# a fault; the other two are the rule working.
MODE_FAULT = "mode range leaves out 0 W"
MODE_RELEASED = "mode released by a higher claim"
MODE_OUTRANKED = "hard limit leaves no room for the mode"
REFUSE_SCOPE = "mis-routed claim"
REFUSE_EMPTY = "no unit available"
REFUSE_SIGN = "opposite directions"
@@ -213,6 +219,10 @@ class Allocation:
# that calls `plan()` for its arithmetic gets None and does not care;
# main.py stamps every record it puts on the write path.
tick: int | None = None
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
# as a software fault. Empty on every tick without a mode.
mode_faults: tuple = ()
@property
def ok(self) -> bool:
@@ -246,11 +256,11 @@ class Allocation:
block the failsafe is worse than the hole it closes.
"""
if tick is None or self.tick is None or self.tick != tick:
return (f"the allocation record on hand was made on tick "
f"{self.tick!r} and this is tick {tick!r} - a record whose "
f"envelopes have moved cannot authorise a write")
return (f"the power split on hand is from tick {self.tick!r}, "
f"not this tick {tick!r}, and an old split cannot allow a "
f"write")
if not self.ok:
return f"the allocator refused this tick ({self.explain()})"
return f"no power split was possible this tick ({self.explain()})"
if unit not in self.shares:
return (f"{unit} has no share in this tick's allocation "
f"({self.explain()})")
@@ -290,6 +300,94 @@ class Allocation:
return out
def unit_interval(b, deciding_priority=None):
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
above the mode's own priority (arbiter._unit_modes() passes None when the
modes hold, and the yielding limit's priority when they give way). `note` says why the
mode did not apply, or is None when it did or there is none:
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
and never force one, so this is a software fault and the mode is dropped.
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
moves every free unit away from 0 in ONE direction, and it starts from 0
only while every interval holds 0.
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
limits win, as they always do.
A unit without mode fields (`mode_priority` absent or None) gets its hard
limits, unchanged: that is every unit when battery modes are off.
"""
lo, hi = b.lo, b.hi
prio = getattr(b, "mode_priority", None)
if prio is None:
return lo, hi, None
mlo, mhi = b.mode_lo, b.mode_hi
if not (mlo <= 0.0 <= mhi):
return lo, hi, MODE_FAULT
if deciding_priority is not None and deciding_priority > prio:
return lo, hi, MODE_RELEASED
nlo, nhi = max(lo, mlo), min(hi, mhi)
if nlo > nhi:
return lo, hi, MODE_OUTRANKED
return nlo, nhi, None
class _Narrowed:
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
__slots__ = ("lo", "hi")
def __init__(self, lo, hi):
self.lo, self.hi = lo, hi
def pin_w(b):
"""The watts a unit's own SET places once held inside its hard limits,
as `plan()` places a standing pin, or None when the unit has no SET."""
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is None or pv is None:
return None
lo, hi = _int_envelope(b)
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
The sum, over live units that do not contradict themselves, of each unit's
interval with its mode applied (`unit_interval()` with no higher claim). A
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
pin stays reachable. Every term holds 0, so the sum does too.
`compute()` keeps the site target inside this, so the loop never asks for
watts no battery may give: the allocator would report them as unmet and the
saturation freeze would park the target on them (SAFETY-73 section 1).
Pure, like `plan()`: it reads numbers and intervals and nothing else.
"""
lo_sum, hi_sum = 0.0, 0.0
for u in sorted(bounds):
b = bounds[u]
if live is not None and u not in live:
continue
if getattr(b, "contradiction", False):
continue
p = pin_w(b)
if p is not None:
lo_sum += min(0.0, p)
hi_sum += max(0.0, p)
continue
lo, hi, _ = unit_interval(b)
lo, hi = _int_envelope(_Narrowed(lo, hi))
if lo > hi:
continue
lo_sum += min(0.0, lo)
hi_sum += max(0.0, hi)
return lo_sum, hi_sum
def _int_envelope(b):
"""A unit's interval, narrowed to the integers a 1 W register can hold.
@@ -385,7 +483,8 @@ def _fill(order, weights, cap, amount):
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
site_priority=None, site_key="site", tick=None) -> Allocation:
site_priority=None, site_key="site", tick=None,
deciding_priority=None) -> Allocation:
"""Divide the site grid target `target_w` across the units in `bounds`.
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
@@ -411,6 +510,9 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
`site_priority` the priority of the SITE claim that chose `target_w`. It is
what a unit's own winning SET is weighed against - see PINS.
`site_key` the name to use when there are no units at all.
`deciding_priority` OPT-11: the priority that decided `target_w`
(`Resolution.deciding_priority`). Each unit's mode range applies
only at or below it - see `unit_interval()`. None applies them.
`tick` an opaque token identifying the tick this record is FOR, handed
straight back on the record. SAFETY-28's write gate compares it
against the tick it is writing on, so that a record cannot be
@@ -497,6 +599,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
dropped = []
ilo, ihi = {}, {}
considered = []
mode_faults = []
for u in units:
b = bounds[u]
if live is not None and u not in live:
@@ -512,7 +615,12 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
# has no interval to allocate into.
dropped.append((u, DROP_CONTRADICTION))
continue
lo, hi = _int_envelope(b)
# OPT-11: the mode layer, when it applies, narrows the hard limits.
# A unit with no mode gets exactly its hard limits, as before.
nlo, nhi, note = unit_interval(b, deciding_priority)
if note == MODE_FAULT:
mode_faults.append((u, MODE_FAULT))
lo, hi = _int_envelope(_Narrowed(nlo, nhi))
if lo > hi:
# An interval that contains no integer. The register is 1 W, so
# there is no number this unit could be commanded that both bounds
@@ -526,7 +634,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
refused=why, tick=tick)
refused=why, tick=tick, mode_faults=tuple(mode_faults))
# --- REFUSAL 2: nobody is available ------------------------------------
if not considered:
@@ -659,4 +767,5 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
plans=tuple(plans), dropped=tuple(dropped),
unmet_w=int(unmet), quantisation_w=quant, tick=tick)
unmet_w=int(unmet), quantisation_w=quant, tick=tick,
mode_faults=tuple(mode_faults))
+197 -9
View File
@@ -86,8 +86,17 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# because trading against the meter is the entire point - the grid-following
# loop would otherwise cancel every charge the moment it created import.
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
# each battery's mode; below the peak guard, to which a mode gives way only as
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
# DECISIONS/OPT-11-battery-modes.md.
P_MODE = 35
P_LOOP = 10 # ordinary grid-following
# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits.
MODE_SOURCE = "mode"
SET = "set"
LIMIT = "limit"
@@ -175,6 +184,12 @@ CLAIM_SCOPE = {
# the banner and the claim an install has had since SAFETY-02 shipped.
# `Controller.cycle()` does not even arm the site detector there.
"sign_site": SCOPE_SITE,
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
# N>=2, while a pack's maintenance drain is pinned, the site target may
# not go below what that pin places: below it a sibling would have to
# charge against the drain (circulation, refused, 0 W). A site `lo`,
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
"drain": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
@@ -226,12 +241,25 @@ CLAIM_SCOPE = {
# reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it.
"pack": SCOPE_UNIT,
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
# nothing in one direction, measured on THIS unit's own reading. A hard unit
# limit that always intersects, so the share goes to the siblings. Filed at
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
"refused": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
# this pack to its floor, charge this pack to full, recalibrate this pack's
# coulomb counter. Two packs recalibrate on their own schedules.
"maintenance": SCOPE_UNIT,
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
# battery's mode, chosen per battery by its owner. A unit claim that never
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
# hard limits, and it gives way to a site limit above P_MODE only as far as
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
# `resolve()` takes it out of stage 2 for the same reason.
"mode": SCOPE_UNIT,
}
@@ -359,6 +387,13 @@ class UnitBounds:
pinned_w: float | None = None
pinned_by: str | None = None
contradiction: bool = False
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
# limits. `mode_priority` is None when no mode claim applies. Whether the
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
# depends on the priority that decided the SITE target.
mode_lo: float = -inf
mode_hi: float = inf
mode_priority: int | None = None
def explain(self) -> str:
if self.contradiction:
@@ -404,6 +439,15 @@ class Resolution:
# rules drifting apart: this is set by the same line that picks the winner
# `explain()` names.
winner_priority: int | None = None
# OPT-11. The higher of the winning set's priority and the priority of the
# limit that bound `target_w`. Whether the modes hold is decided from the
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
# never meets a site `lo`.
deciding_priority: int | None = None
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
mode_fault: str | None = None
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
@@ -427,7 +471,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
of the fault this card removes, and the reason `resolve()` partitions rather
than filters twice.
"""
sets = sorted((c for c in unit_claims if c.kind == SET),
sets = sorted((c for c in unit_claims
if c.kind == SET and c.source != MODE_SOURCE),
key=lambda c: c.priority, reverse=True)
gate = pinned_w = pinned_by = None
if sets:
@@ -435,19 +480,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
lo, hi = -inf, inf
lo_src = hi_src = None
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
# limits are read. The lowest mode priority is kept (the first to release).
mode_lo, mode_hi, mode_priority = -inf, inf, None
for c in unit_claims:
if c.kind != LIMIT or (gate is not None and c.priority < gate):
continue
if c.source == MODE_SOURCE:
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
mode_priority = (c.priority if mode_priority is None
else min(mode_priority, c.priority))
continue
if c.lo > lo:
lo, lo_src = c.lo, c.source
if c.hi < hi:
hi, hi_src = c.hi, c.source
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
contradiction=lo > hi)
contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi,
mode_priority=mode_priority)
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
scope_errors=(), site_priority=None,
deciding_priority=None) -> tuple:
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
@@ -485,11 +541,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
"""
return allocate_record(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority)[:2]
site_priority=site_priority,
deciding_priority=deciding_priority)[:2]
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None, tick=None) -> tuple:
scope_errors=(), site_priority=None, tick=None,
deciding_priority=None) -> tuple:
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
@@ -501,7 +559,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority, site_key=ACTUATOR_SITE,
tick=tick)
tick=tick, deciding_priority=deciding_priority)
return rec.shares, rec.ok, rec
@@ -538,19 +596,26 @@ def site_resolve(claims: list) -> Resolution:
considered = [f"{c.source}:{c.kind}" for c in claims]
if lo > hi:
# OPT-11: 0 W here, and every limit that took part counts as deciding.
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered,
winner_priority=winner.priority)
winner_priority=winner.priority,
deciding_priority=max(winner.priority,
lo_src.priority,
hi_src.priority))
target = max(lo, min(hi, winner.value))
bound_by = None
deciding = winner.priority
if target != winner.value:
binder = lo_src if target == lo else hi_src
if binder is not None:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
deciding = max(deciding, binder.priority)
return Resolution(float(target), winner.source, bound_by, False, considered,
winner_priority=winner.priority)
winner_priority=winner.priority,
deciding_priority=deciding)
def resolve(claims: list, *, units=None, weights=None, live=None,
@@ -600,6 +665,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
written down.
"""
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
# OPT-11: a mode claim on the site (N=1, where every source routes there)
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
# the same release rule stage 3 uses.
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
if site_modes:
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
if units is None:
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
units = tuple(sorted(units))
@@ -611,6 +682,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
# `claims`, so this is the shipped arbiter on the shipped list.
out = site_resolve(site_claims)
if site_modes:
_site_mode(out, site_modes, site_claims)
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
@@ -643,10 +716,125 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
# own direction can differ from the target's sign under a pin, in which case
# the weighting is a worse preference and still cannot be unsafe).
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
# Before the weights, because a limit above the modes may move the target.
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
for u in bounds}
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority,
tick=tick)
tick=tick, deciding_priority=gate)
return out
def _limits_above(site_claims: list, prio: int) -> tuple:
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
These are what a mode may never take the site target outside of: the peak
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
"""
lo, hi = -inf, inf
lo_src = hi_src = None
for c in site_claims:
if c.kind != LIMIT or c.priority <= prio:
continue
if c.lo > lo:
lo, lo_src = c.lo, c
if c.hi < hi:
hi, hi_src = c.hi, c
return lo, lo_src, hi, hi_src
def _yield_to(out: Resolution, t1: float, above: tuple):
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
above the mode need (`_limits_above()`). Returns the claim that moved it,
or None when `t1` already satisfies them, and sets `out.target_w`."""
lo, lo_src, hi, hi_src = above
t2 = max(lo, min(hi, t1))
out.target_w = float(t2)
if t2 == t1:
return None
binder = lo_src if t2 == lo else hi_src
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
else binder.source)
out.deciding_priority = max(out.deciding_priority or binder.priority,
binder.priority)
return binder
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
A SET above the mode (a safety stop) decides alone. Otherwise the target is
clamped into the mode range and then back into the site limits above the
mode, so the mode gives way only as far as those limits need: a peak floor
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
range without 0 W is a software fault that drops the mode
(`out.mode_fault`). Clamping one number cannot contradict anything.
"""
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
lims = [c for c in modes if c.kind == LIMIT]
if not lims or out.contradiction:
return
lo = max(c.lo for c in lims)
hi = min(c.hi for c in lims)
prio = min(c.priority for c in lims)
if not (lo <= 0.0 <= hi):
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
return
if out.winner_priority is not None and out.winner_priority > prio:
return
before = out.target_w
t1 = max(lo, min(hi, before))
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
and t1 != before:
c = lims[0]
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
"""OPT-11 at N>1: `(gate, held)` for stage 3.
`gate` is the `deciding_priority` handed to the allocator: None applies
every unit's mode, a number above P_MODE releases them. `held` is the set
of units whose mode holds them back in the target's direction; while the
modes are released they weigh 0, so they carry only what the other units
cannot (the allocator's last-resort pass).
The rule matches N=1: a SET above the mode decides alone. Otherwise the
target is clamped into what the units can place with their modes
(`allocator.loop_bound()`) and back into the site limits above the mode.
If that point is still placeable with the modes, they hold and the target
is unchanged (what they cannot place is reported unmet, as before). If a
limit above the mode needs more, the target moves only that far, and the
modes are released for it.
"""
mode_units = [u for u, b in bounds.items()
if getattr(b, "mode_priority", None) is not None]
if not mode_units or out.contradiction:
return out.deciding_priority, set()
prio = min(bounds[u].mode_priority for u in mode_units)
if out.winner_priority is not None and out.winner_priority > prio:
return out.deciding_priority, set()
clo, chi = allocator.loop_bound(bounds, live)
t1 = max(clo, min(chi, out.target_w))
before = out.target_w
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
if binder is None:
out.target_w = float(before)
return None, set()
held = set()
for u in mode_units:
b = bounds[u]
nlo, nhi, note = allocator.unit_interval(b)
if note is not None:
continue
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
held.add(u)
return binder.priority, held
+2 -4
View File
@@ -262,10 +262,8 @@ def threshold_eur_kwh(opts: dict) -> float:
v = float(price_option(opts, "cycle_cost_threshold_eur"))
if not math.isfinite(v) or v < 0:
raise ValueError(
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
f"in EUR/kWh, so it is non-negative and finite - a negative "
f"threshold is one every spread clears, which is arbitrage with the "
f"brake removed.")
f"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
f"it to 0 or more, in EUR/kWh.")
return v
+5 -4
View File
@@ -325,12 +325,13 @@ class CirculationWatchdog:
return False
self.tripped = True
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
self.reason = (
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
f"continuously - the meter cannot see it, because it nets to zero "
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
f"{self.cfg.hold_s:.0f} s)")
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
f"over the limits of {self.cfg.min_w:.0f} W and "
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
f"nets to zero there")
return True
def held_s(self, now: datetime) -> float:
+102 -24
View File
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
silent on the exact configuration the whole ticket is about. The winding is
visible one layer up, at the integrator bound, and that is why `Decision` now
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
later stages overwrite: a cycle that clamps the integrator and is then slew
@@ -141,6 +142,8 @@ import math
from dataclasses import dataclass
from datetime import datetime
from . import messages
# The reason code, distinct from any saturation-freeze code by construction:
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
REASON_CODE = "OUTPUT_CLAMP"
@@ -200,9 +203,8 @@ REASON_CODE = "OUTPUT_CLAMP"
# ceiling rests on one number a test author chose; if that number is ever
# revisited, this constant moves with it.
#
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
# in `loop_clamp_note()` below. Nothing here is
# suppressed: every engagement is still counted, the action-log row is written on
# every engaged cycle regardless, and the rising edge of every episode alarms
# unconditionally, so no alarm is DROPPED - only delayed.
@@ -234,6 +236,46 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
# final full stop, the line adds it. The line around them says "software
# fault" for an over-rating, so the reason does not. Each is one sentence, so
# the logged line stays within 3 sentences and about 30 words.
def loop_clamp_note(d) -> str:
"""The loop-side alarm text for one `control.Decision`, or "".
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
only a command over the rating is a software fault. Every number is the
decision's own, i.e. from the tick that clamped.
"""
if d.clamped:
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
if d.i_clamped:
return (f"{REASON_CODE}: the correction the add-on builds up reached "
f"its {d.i_bound_w:+.0f} W limit, command now "
f"{d.target_w:+.0f} W")
return ""
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
One inverter reads as `describe()`. Several read as one sentence of
asked/sent pairs, so the line stays one sentence however many inverters
there are. Which limit cut each one back is on its own action-log row
(`describe()`) and in the notification."""
if len(named) == 1:
name, r = named[0]
return f"{name}: {r.describe()}" if name else r.describe()
return f"{REASON_CODE}: " + ", ".join(
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
for name, r in named)
@dataclass(frozen=True)
class ClampResult:
"""What the guard did, with the RAW value preserved.
@@ -250,10 +292,12 @@ class ClampResult:
bound: str = "" # "" | "max_w" | "device" - which stage bit
def describe(self) -> str:
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
f"winding")
# The sent value IS the limit that bound, so the rating is not
# repeated: on "max_w" it equals the rating, and on "device" the
# rating was not what cut it back.
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
f"{self.applied_w:+.0f} W, "
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
def is_commandable(value) -> bool:
@@ -333,32 +377,72 @@ class ClampWatchdog:
def __init__(self):
self.active = False
self.engagements = 0 # every engagement, never deduplicated
# UX-08: the same count split by alarm type, so the status page names
# each with its own alarm's title after the episode clears.
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
# UX-08: the numbers behind `reason`, for the notification - set under
# the same rule as `reason`, so both always describe the same tick.
self.facts = ()
self.clean_cycles = 0
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
# spoken yet - the two the escalation clause needs.
self.run_len = 0
self.spoken = False
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
# has THAT spoken? A bug has its own budget, so anti-windup notices can
# never delay it; with bug=True throughout, cadence is as before.
self.bug = False
self.bug_spoken = False
self.last_bug_alarm: datetime | None = None
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
def _due(self, last, now) -> bool:
return last is None or (now - last).total_seconds() >= REASSERT_S
def _speak(self, now, bug) -> bool:
self.last_alarm = now
self.spoken = True
if bug:
self.last_bug_alarm = now
self.bug_spoken = True
return True
def note(self, engaged: bool, reason: str, now: datetime,
bug: bool = True, facts=()) -> bool:
"""Advance one cycle. True when the caller should raise the alarm.
`now` is the caller's clock, the same discipline as
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
is the integrator bound (SAFETY-40): counted and notified like the
rest, but it never replaces a bug's text within an episode.
"""
if engaged:
self.engagements += 1
if bug:
self.over_rating += 1
else:
self.bound += 1
self.clean_cycles = 0
self.reason = reason
self.run_len += 1
if not self.active:
rising = not self.active
if rising:
self.active = True
self.since = now
self.episodes += 1
self.spoken = False
self.bug = False
self.bug_spoken = False
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
self.bug = self.bug or bug
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
return self._speak(now, True)
if rising:
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
# not used to be. It fired unconditionally, so a condition that
# engages and clears repeatedly raised one notification PER
@@ -384,11 +468,8 @@ class ClampWatchdog:
# still written on every engaged cycle, and a genuine runaway -
# which by definition keeps engaging - is picked up by the
# re-assert path within REASSERT_S. Delay, not loss.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
# budget was spent by an EARLIER, unrelated episode speaks anyway
@@ -396,15 +477,10 @@ class ClampWatchdog:
# engages every cycle and reaches it; the scattered one-cycle
# railing that produced the live flood never does.
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
self.last_alarm = now
self.spoken = True
return True
return self._speak(now, self.bug)
# Still engaged: re-assert on the slow cadence, not every cycle.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
self.run_len = 0
@@ -420,5 +496,7 @@ class ClampWatchdog:
# above. The episode ends; the notification budget does not reset
# with it.
self.reason = ""
self.facts = ()
self.bug = False
self.clean_cycles = 0
return False
+3 -4
View File
@@ -413,10 +413,9 @@ function showProblems(payload){
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
'The add-on would refuse to start on it. Saving is blocked until these '
+ 'are fixed, and nothing has been written. Each message below comes '
+ 'from the same check that runs at start.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
+4 -1
View File
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
# names a setting of one inverter in the list, which is changed under
# `inverters`, and it must not also land beside the flat `max_w`.
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
+121 -1
View File
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
"""
import math
from math import inf
from dataclasses import dataclass
# ---------------------------------------------------------------------------
@@ -265,6 +266,38 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
# ---------------------------------------------------------------------------
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
#
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
# saturation freeze below holds the command where it stood (the recorded
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
# carried between cycles like `sat_count`, and left again when the loop wants
# the other direction, when a small probe is answered, or when the caller
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
#
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
# test_refusal.py pins the inequality. The cadence and length are the owner's
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
# (17 s is how long a healthy reading needs to show a command).
REFUSED = "battery refuses"
PROBING = "battery probe"
PROBE_W = 200.0
PROBE_ANSWER_W = PROBE_W / 2
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
# (review of #141). The capture's refusals read -4 W.
REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0
PROBE_S = 34.0
# OPT-11. The loop's reason when the battery modes, not the meter, set the
# command: every battery that may move this way is at its mode's limit.
MODE_BOUND = "mode-bound"
@dataclass(frozen=True)
class Tuning:
@@ -340,6 +373,15 @@ class Decision:
# the comment at the bound below, and clamp.py.
i_clamped: bool = False
clamped: bool = False
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
# the bound the integrator reached, and the command before/after the
# rating clamp. `target_w` is this tick's emitted command.
i_bound_w: float = 0.0
requested_w: float = 0.0
clamped_w: float = 0.0
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
def slew_limit_w(tuning: Tuning) -> float:
@@ -436,6 +478,9 @@ def compute(
tuning: Tuning,
sat_count: int = 0,
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -443,8 +488,27 @@ def compute(
`grid_w` net grid power, + = importing
`actual_w` what the inverter reports it is doing, + = discharging
`i_w` the carried integrator, or None to seed it from `prev_w`
`refused` SAFETY-52's carried refused direction, or None to leave it out
(the `i_w=None` equivalence path does)
`probe` the caller's clock says a refused direction may try PROBE_W now
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
# mode may do. It is dropped rather than obeyed (a software fault, and
# main.py never builds one: every term of `loop_bound()` holds 0).
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
bound = None
# ...and a side of it that reaches the rail is left to the rail, so that
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
# every battery on, the bound IS the rail, and the law is exactly as it was.
if bound is not None:
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
else tuning.integrator_max_w)
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
float(bound[1]) if bound[1] < _rail else inf)
# --- saturation, WITH the duration term -------------------------------
# Command and actual diverging means the inverter cannot follow - it is at
@@ -547,6 +611,15 @@ def compute(
else:
i_w = max(moved, i_w)
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
# bound is not. It is what makes the worst-case unwind time finite and
# knowable instead of a function of how long the error happened to stand.
@@ -563,6 +636,7 @@ def compute(
# winding, "clamped" is a command that came out over the rating anyway.
reason = "i-clamped"
i_w = bounded
i_bound_w = bounded if i_clamped else 0.0
want = i_w
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
@@ -574,6 +648,7 @@ def compute(
# An early draft applied a floor after the clamp and let demand escape it.
target = max(-tuning.max_w, min(tuning.max_w, want))
clamped = target != want
clamped_w = target
if clamped:
reason = "clamped"
@@ -731,6 +806,43 @@ def compute(
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
wants = refused * i_w > 0
i_w = 0.0
target = refused * PROBE_W if probe and wants else 0.0
reason = PROBING if probe and wants else REFUSED
# --- OPT-11: the command stays inside what the modes allow -------------
# Applied after the slew limit and the freeze, so a mode that closes drops
# the ask at once rather than one slew step per cycle. That moves no
# battery further than the allocator would: it places at most this bound
# when the loop decides. What it removes is the unmet part, which would
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
# freeze on watts no battery was allowed to give.
if bound is not None:
inside = max(float(bound[0]), min(float(bound[1]), target))
if inside != target:
target = inside
reason = MODE_BOUND
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
@@ -738,9 +850,17 @@ def compute(
# 10 W simply avoids a visible staircase on dashboards.
step = max(1, int(tuning.step_w))
target = round(target / step) * step
if bound is not None:
# OPT-11: rounding must not step outside the bound. The bound holds
# 0 W, so one step toward 0 is always inside it.
if target > bound[1]:
target -= step
elif target < bound[0]:
target += step
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped)
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w), refused)
def maintenance_charge_floor(
+64 -212
View File
@@ -175,6 +175,8 @@ import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
from . import messages
class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by
@@ -199,7 +201,7 @@ RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
# index id -> the human name of the series it refers to. One entry today; a
# second index is a new row here plus a fetch path, not a new grammar.
INDEXES: dict = {
"epex_be": "EPEX BE day-ahead, EUR/MWh",
"epex_be": "Belgian day-ahead price, EUR/MWh",
}
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
@@ -285,87 +287,28 @@ _LEGS = {
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
zero_factor=" and trading never finds a price difference",
flat="use “Fixed or day/night tariff” (static_profile) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
card_note="The add-on needs it to check your two numbers",
card_causes=("the units, the VAT, and that network charges are not "
"in the offset"),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
when="Fill it in only if your injection price follows the market.",
zero_factor="",
flat="use “Injection price” (export_price_eur_kwh) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
card_note=("It is optional here. Without it the add-on only checks "
"the size of your two numbers"),
card_causes="the units, and that the VAT is 0 for injection",
),
}
@@ -488,16 +431,12 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
f"{option}.{field} is {value!r}, which is not a number. Write a "
f"plain number, for example 0.102.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
f"{option}.{field} is {value!r}, which is not finite. Write a "
f"plain number, for example 0.102.")
return float(value)
@@ -517,11 +456,8 @@ def _card(raw, leg: _Leg = None) -> tuple:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
"the three figures from your tariff card, for example "
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
@@ -531,16 +467,13 @@ def _card(raw, leg: _Leg = None) -> tuple:
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
f"only {list(_CARD_KEYS)}.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
@@ -594,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f" {leg.example}\n"
f"{leg.when}")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation. {leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"{option} is {type(value).__name__}, not a JSON object with "
@@ -612,34 +543,21 @@ def parse_price_formula(value, *, option: str = "price_formula"):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
f"coefficients can be graded against your card's printed price "
f"and an arbitrary expression cannot be graded against anything. "
f"If your contract needs a shape this cannot express, it needs a "
f"new named field here - say which shape.")
f"{option} has unknown field(s) {unknown}. It takes only "
f"{list(_TOP_KEYS)}.")
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
f"and guessing which series your supplier meant would price your "
f"battery against the wrong curve.")
f"{option}.index is {value.get('index')!r}. The add-on can only "
f"fetch "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
f"on the mean of each hour's quarters. Which one your contract "
f"uses is on your card and cannot be inferred from the feed.")
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
f"of {list(RESOLUTIONS)}, as your tariff card says.")
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
# factor-1000 class with a friendly face: the add-on would silently assume
@@ -648,12 +566,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
f"{option}.units is {value.get('units')!r}. Choose one of "
f"{sorted(UNITS)}, as printed beside the formula on your card.")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
@@ -667,10 +581,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
f"{option}.factor is 0, so every hour gets the same {leg.what} "
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -678,22 +590,16 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. {leg.neg_factor}.")
f"{option}.factor is {factor:g}, which turns the market upside "
f"down. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
f"and 30, for example 6.")
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
@@ -771,33 +677,15 @@ def parse_export_formula(value):
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
⚠️ IT NAMES THE DISCREPANCY AND THE SIX WAYS IT HAPPENS. "Invalid
configuration" would be true and useless: every one of these errors looks
exactly like a working tariff from the status page, which is why the check
exists at all.
It names both numbers and what to check. The usual causes are in DOCS.md,
because each of these errors looks like a working tariff on the status page.
"""
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
f"{spec.units}, but card.expect_incl_vat says "
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
f"last digit a card prints). One of the two sides is wrong and the "
f"add-on cannot tell which, so it refuses rather than price a battery "
f"on it. The usual causes, in the order they happen: the coefficients "
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; {leg.card_causes}")
f"{spec.option} gives {got:.4f} "
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
# --------------------------------------------------------------------------- #
@@ -852,20 +740,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
f"retail tariff. The card self-check passed, so the coefficients and "
f"the printed price AGREE - they simply agree on a number no Belgian "
f"bill carries, which is what a whole card read in the wrong units "
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
f"once cancels out of that check and cannot cancel out of this one). "
f"Check `units` against the unit printed beside your formula. "
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
f"legitimately fall outside the band, and a negative day-ahead hour - "
f"the best charging opportunity of the year - is never refused.")
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
f"{all_in:.5f} EUR/kWh with “Distribution, levies and VAT” "
f"(price_taxes_eur_kwh), outside the plausible band of "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh. Check \"units\" against the unit beside your formula.")
def implausible_injection(spec: FormulaSpec):
@@ -914,36 +792,16 @@ def implausible_injection(spec: FormulaSpec):
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
f"\"units\" ({spec.units!r}) against the unit beside your "
f"formula.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
f"unit.")
return None
@@ -957,15 +815,9 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
f"however far they moved - and the card self-check cannot see it, "
f"because it grades your coefficients against the card you typed, not "
f"the card against your current contract. Not refused: ENGIE freezes "
f"coefficients for the contract's duration, so an old card is often "
f"exactly right. Worth re-reading your klantenzone once.")
f"{spec.option}'s card is dated {spec.card_month}, "
f"{(now - dated).days} days ago. The add-on still starts, but if your "
f"contract changed since, copy the new figures from your supplier.")
# --------------------------------------------------------------------------- #
+262
View File
@@ -0,0 +1,262 @@
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
expensive hours? If not, how much should it take from the grid?
Pure: the forecast (`solarfc`), the house profile, the price runs and the
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
turns the level into permission to charge from the grid; main.py rebuilds the
plan with the price runs (at most every 15 minutes) and keeps a level unless
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
* `sun`: trust x the solar the house will not use, between now and the
expensive run the cheap run is for, x the charge efficiency:
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
* Solar first batteries take it first, up to the ceiling. What is left is
shared by the Leftover solar batteries by their room.
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
the design (section 1). Skipping the sun's share cost money in the replay
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
* A Leftover solar battery charges only up to what the house will draw from
it in the expensive runs before the next cheap run, less its share:
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
shortfall, not up to the ceiling.
* Solar first never charges from the grid (owner, 2026-09-29).
The house profile is the add-on's own measurement, grid + battery power, per
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
median of that hour. It is net of the solar the house used itself, so for
the solar sum it is never taken below the night-time median (`base()`).
The site's own solar readings are not used: TEL-11 keeps them out of every
control decision.
"""
import statistics
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .tariff import BILLING_TZ
EFF = 0.95 # charge (and discharge) efficiency
HYST_KWH = 1.0 # a kept level moves only by this much...
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
PROFILE_DAYS = 7
MIN_HOURS = 20 # hours of a day recorded before it counts
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
DEFAULT_TRUST_PCT = 70
# Site verdicts (the page's forecast line).
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
# --------------------------------------------------------------------------- #
# The house profile
# --------------------------------------------------------------------------- #
def _local(t: datetime):
return t.astimezone(BILLING_TZ)
class HouseProfile:
"""Hourly means of the house's net use (grid + battery power), kept per
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
store holds `dump()`; `load()` takes it back and drops what is bad."""
def __init__(self, data=None):
self.days = {}
self._cur = None # (day, hour, sum, n)
if isinstance(data, dict):
self.load(data)
def load(self, data):
for day, hours in data.items():
try:
datetime.strptime(str(day), "%Y-%m-%d")
except ValueError:
continue
if not isinstance(hours, dict):
continue
row = {}
for h, v in hours.items():
try:
hi, w = int(h), float(v)
except (TypeError, ValueError):
continue
if 0 <= hi < 24 and abs(w) < 1e6:
row[hi] = w
if row:
self.days[str(day)] = row
def dump(self) -> dict:
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
for d, r in sorted(self.days.items())}
def add(self, now: datetime, house_w) -> bool:
"""One reading. Returns True when an hour closed (then store it)."""
lt = _local(now)
key = (lt.strftime("%Y-%m-%d"), lt.hour)
closed = False
if self._cur is not None and self._cur[:2] != key:
day, hour, s, n = self._cur
if n >= 60: # about 5 minutes of readings
self.days.setdefault(day, {})[hour] = s / n
closed = True
self._cur = None
self._prune(lt)
if house_w is None:
return closed
if self._cur is None:
self._cur = (key[0], key[1], 0.0, 0)
d, h, s, n = self._cur
self._cur = (d, h, s + float(house_w), n + 1)
return closed
def _prune(self, lt):
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
was wrong once) would never age out, so it goes too."""
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
today = lt.date().isoformat()
for d in [d for d in self.days if d < keep or d > today]:
del self.days[d]
def ready(self) -> bool:
"""At least one day with `MIN_HOURS` hours recorded."""
return any(len(r) >= MIN_HOURS for r in self.days.values())
def hour_w(self, hour: int):
vals = [r[hour] for r in self.days.values() if hour in r]
return statistics.median(vals) if vals else None
def base(self):
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
return statistics.median(vals) if vals else None
def estimator(self):
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
if not self.ready():
return None
base = self.base() or 0.0
table = {}
for h in range(24):
w = self.hour_w(h)
table[h] = base if w is None else w
return lambda h: table[h], max(0.0, base)
# --------------------------------------------------------------------------- #
# The plan
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Plan:
verdict: str # SUNNY / PARTLY / DARK
sun_kwh: float # solar the batteries can count on
fc_kwh: float # the forecast over the same span
until: datetime # the expensive run the plan is for
targets: dict # name -> level % (Leftover solar only)
need_kwh: dict # name -> kWh from the grid (0: none)
share_kwh: dict # name -> kWh of solar counted for it
def _hour_slices(t0, t1):
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
out, t = [], t0
while t < t1:
lt = _local(t)
nxt = (lt.replace(minute=0, second=0, microsecond=0)
+ timedelta(hours=1)).astimezone(timezone.utc)
e = min(nxt, t1)
out.append((t, e, lt.hour))
t = e
return out
def _room(p, to_pct):
if p.capacity_kwh is None or p.soc is None:
return 0.0
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
p.capacity_kwh)
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
ceiling_pct):
"""The day plan, or None when there is none to make: no forecast, no
house profile yet, no price plan, or no expensive run after the cheap
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
`modetrade.Pack`s."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
house_w, base_w = house
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
if until is None:
return None
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
sun = fc_sum = 0.0
for s, e, h in _hour_slices(now, until.start):
pv = forecast.between(s, e)
if pv is None:
return None # the span reaches a day with no forecast
hrs = (e - s).total_seconds() / 3600.0
use = max(house_w(h), base_w) * hrs / 1000.0
sun += max(0.0, pv - use)
fc_sum += pv
sun *= trust * EFF
usable = [p for p in packs if p.use and p.live and p.idle
and p.soc is not None and p.capacity_kwh]
sf = [p for p in usable if p.mode == SOLAR_FIRST]
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
sf_room = sum(_room(p, ceiling_pct) for p in sf)
excess = max(0.0, sun - sf_room)
rooms = {p.name: _room(p, ceiling_pct) for p in price}
total_room = sum(rooms.values())
# What the house draws in the expensive runs from the one this plan is
# for until the next cheap run (or a day), from the profile.
horizon = until.start + timedelta(hours=24)
use_kwh = 0.0
for g in runs.give:
if g.start < until.start or g.start >= horizon:
continue
for s, e, h in _hour_slices(g.start, g.end):
use_kwh += max(0.0, house_w(h)) * (
e - s).total_seconds() / 3600.0 / 1000.0
caps = {p.name: float(p.capacity_kwh) for p in price}
cap_sum = sum(caps.values()) or 1.0
targets, need, share = {}, {}, {}
for p in price:
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
sh_pct = sh / caps[p.name] * 100.0
want = use_kwh * caps[p.name] / cap_sum / EFF
top = min(float(ceiling_pct),
float(floor_pct) + want / caps[p.name] * 100.0)
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
share[p.name] = round(sh, 3)
if sun < sf_room:
verdict = DARK
elif any(v > 0.05 for v in need.values()):
verdict = PARTLY
else:
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share)
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
"""Hysteresis: a battery keeps its old level unless the new one differs
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
(whichever is less). A battery new to the plan takes its level at once;
one that left the plan is dropped at once."""
out = {}
for name, level in new.items():
was = (old or {}).get(name)
cap = float(capacity.get(name) or 0.0)
if was is None or cap <= 0:
out[name] = level
continue
moved = abs(level - was) / 100.0 * cap
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
out[name] = level if moved >= step else was
return out
+8 -6
View File
@@ -424,15 +424,17 @@ class FreezeWatchdog:
# page was telling, printed into the log and the HA notification where
# it would outlive the page.
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
capped = ("" if ceiling >= abs(float(max_w))
else f", capped from max_w {float(max_w):.0f} W by the "
f"setpoint device's own rating")
f"inverter's own rating")
self.reason = (
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
f"without its measurement changing while the command stands at "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
f"METER_FROZEN: the meter has not changed for "
f"{float(unchanged_s):.0f} s while the command is "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
f"command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
if self.active:
return False
self.active = True
+333 -25
View File
@@ -175,6 +175,124 @@ only one of them is WIRED - see `BOUNDS` below and the note on SoC.
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
below takes its obligation term from the caller precisely so that an energy
integral can be handed in where command travel is handed in here.
THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57)
==============================================================================
⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES
THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False,
which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be
anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and
the class docstring's clearing rule, "an arrival carrying a DIFFERENT number",
asks the register for evidence that only a command could have produced. A pack
sitting at its BMS floor answers the same integer watt for hours. **The state
had no exit reachable from inside it**, and the operator-facing line said the
opposite: *"It clears itself the moment the register reports a different
value."*
MEASURED ON THE LIVE SITE, 2026-09-21, and committed as
`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by
`tools/decode_bus_capture.py`:
10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just
reversed from -447 W of charge into discharge; the BMS refuses
and the inverter idles. The register is HEALTHY and TRUTHFUL.
10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the
register is obliged to follow it.
10:52:00 the conjunction arms. `Inverter.batt` becomes None.
10:52:21.7 the site commands 0 W - on the wire, in the capture.
... 0x0518 does not change value for 850.0 s. The setpoint does not
change value for 790.0 s. Nothing in this add-on can end it.
11:04 a human restarts the add-on.
11:05:31.7 the restarted loop commands -400 W of charge, which the pack
ACCEPTS at 11 %, and the register moves 3 s later.
⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few
hundred watts, which twitches 0x0518 and satisfies the clearing rule by
accident. Replaying the committed 16-day loop capture
(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at
1 Hz gives **93 trips**, and the time from each trip to the register's next
change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
RELEASE <=> the verdict is standing
AND |standing command| < MOVE_W has held continuously for
>= RELEASE_S
On a release the latch clears and a NEW grading window opens at that instant:
the register is re-graded, from scratch, against the commands issued AFTER it.
Nothing about the register's own history is rewritten - `changed_mono`,
`held` and `unchanged_s()` go on reporting that the register has not moved for
850 s, because it has not.
⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND
RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its
own. The question the bound answers is *has the register had its chance to
answer the command that is now standing?*, and that is exactly the 17.0 s
already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it
the release happens before the cheaper exit - the register moving by itself -
could have occurred. Which half is safe is the opposite of SAFETY-13's, and for
the reason that card states in its §4.2 table: being wrong LONG here is the
failure being fixed (a site parked while it exports), and being wrong SHORT
costs one re-grading window, which is bounded below.
WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the
duration of the new window the consumers get the held number again, which is
the exposure this module was written to close. Driven at the live install's
shape against a register frozen for good (`test_freshness.py` part 10c), over
1200 s:
shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 %
SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 %
⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an
occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S`
(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is
bounded by construction, and that bound is the one thing that makes it payable:
the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the
circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A
VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS
both windows rather than accumulating into them.
That inequality is the same one part 6 already grades, and it is why the window
is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look
better. Neither detector can be made to say anything by a held number under
this scheme - which is exactly the state the shipped code leaves them in as
well, since a permanent None clears both windows for ever. **So nothing that
SAFETY-37 detects is given back.** What the held number does reach for those
35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which
responds to a large unexplained divergence by FREEZING the output - the
conservative direction, and SAFETY-13's business. And the command itself is
derived from the P1 meter, never from `batt`, so a site regulating through a
re-grading window is regulating on the signal it always regulates on.
**A register frozen under a command that never travels 250 W is still not
detected at all** (survivor 2, unchanged), and a dead publisher whose value
alternates is still invisible (survivor 3).
⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live
episode's register refused a DISCHARGE into an 11 % pack and then followed a
CHARGE immediately; a refusal in one direction is not evidence about the other,
and a gate that knew the pack's floor could re-grade on the first command that
crosses it instead of on a timer. That needs SoC, which this module deliberately
does not take (see the `soc` row), and the pack floor, which is SAFETY-32's
number. It would shrink the 52 % above; it is not needed to give the state an
exit, and mixing it in here would make the exit depend on a second measurement
being right.
⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the
whole episode: it does not tell a BMS refusal apart from a dead register. The
site above was parked for 790 s by a register that was working perfectly, and
after this change it is parked for `stale_input_s + RELEASE_S` instead - about
32 s on the shipped default - and then regrades. Telling the two apart needs
the pack's own floor, which is SAFETY-32's number and not this module's input;
the wording the operator reads about it is SAFETY-56's.
"""
import time
@@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0
# and goes red if the two drift.
MOVE_W = 250.0
# SAFETY-57. How long the obligation must have been ABSENT before a standing
# verdict is withdrawn and the register re-graded. See the module docstring's
# last section for the live episode this exists for and for which half of the
# band is the safe one.
#
# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the
# derivation rather than a convenience. The question is "has the register had
# its chance to answer the command standing NOW?", which is the same physical
# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s
# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the
# cheaper exit - the register moving by itself - before it could have happened.
RELEASE_S = BATT_OBLIGED_S # 17.0
@dataclass(frozen=True)
class Bound:
@@ -219,6 +350,7 @@ class Bound:
signal: str
unchanged_s: float
move_w: float
release_s: float
wired: bool
derivation: str
@@ -233,13 +365,19 @@ BOUNDS = {
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
unchanged_s=BATT_UNCHANGED_S,
move_w=MOVE_W,
release_s=RELEASE_S,
wired=True,
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]. "
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
move_w=float("inf"),
release_s=float("inf"),
wired=False,
derivation="TEL-08. Command travel is the WRONG obligation term for "
"SoC - a rested pack holds a percent for hours under a 0 W "
@@ -263,10 +401,33 @@ class Freshness:
Declaring the reading stale makes `Controller.inputs_ok` False, which
commands 0 W (§11.1 rule 1), which drops the standing command below
`MOVE_W`, which un-arms the conjunction, which would return the held number
to the consumers - and round again every read. So the ONLY thing that clears
the latch is an arrival carrying a DIFFERENT number, which is exactly the
evidence the verdict was about. A register that is working clears it on its
next 10 s publish; a register that is not, does not.
to the consumers - and round again every read. So an arrival carrying a
DIFFERENT number clears the latch, which is exactly the evidence the verdict
was about. A register that is working clears it on its next 10 s publish; a
register that is not, does not.
⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE
THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself
produces is 0 W, and at 0 W the register is obliged to be nowhere - so
"report a different value" is evidence a parked site cannot ask for. The
live site sat at 0 W for 790 s on 2026-09-21 with a register that was
working correctly, and was freed by a human. `_release()` below withdraws a
verdict whose obligation has been gone for `bound.release_s`, and opens a
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
different number. `graded_from` / `standing_at_grade` are the window the
VERDICT is computed over, which a release re-opens. They are the same thing
until the first release and diverge afterwards, and `unchanged_s()` - the
number an operator reads - is always the register's.
"""
def __init__(self, bound: Bound):
@@ -281,10 +442,20 @@ class Freshness:
# ability to fetch it.
self.held = None
self.changed_mono = None
# The standing command at that same instant. The conjunction compares
# against THIS, not against the previous cycle's command: what matters
# is whether the register has followed anything since it last moved.
self.standing_at_change = None
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
# register's own history above, and the standing command at the instant
# it opened. The conjunction compares against THIS, not against the
# previous cycle's command: what matters is whether the register has
# followed anything since the current question was put to it. A register
# that moves opens a window, so until the first release these two are
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
self.released_at = None
self.releases = 0
self.stale = False
self.reason = ""
self.arrivals = 0
@@ -311,20 +482,67 @@ class Freshness:
if self.changed_mono is None or value != self.held:
self.held = value
self.changed_mono = float(now_mono)
self.standing_at_change = (None if standing_w is None
else float(standing_w))
# Movement is the ONLY thing that clears the latch. See the class
# docstring on the 1 Hz oscillation this prevents.
self._open_window(standing_w, now_mono)
# Movement clears the latch, because it is exactly the evidence the
# verdict was about. See the class docstring on the 1 Hz oscillation
# the latch prevents, and `_release()` for the other way out.
self.stale = False
self.reason = ""
self.last = value
# ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO
# BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is
# a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural
# rather than a missing fixture, so no fixture could have closed it:
# `_grade()` acts only when `|standing| >= move_w` and `_release()` only
# when `|standing| < move_w`, so at most one of them can do anything on
# any given read, whichever runs first. Release is written first because
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
"""Start grading the register again, from this instant (SAFETY-57).
Called when the register moves - the window and the register's history
coincide there - and when a verdict is withdrawn, where they do not.
"""
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is
maintained on every arrival, latched or not, so the elapsed rest is the
real one and not "time since we last looked". An obligation reappearing
resets it to None, which is the whole point: a site that goes back to
asking the register for 250 W of movement has put the question back, and
the verdict stands until the question is withdrawn again.
"""
obliged = (standing_w is not None
and abs(float(standing_w)) >= self.bound.move_w)
if obliged:
self.rested_since = None
return
if self.rested_since is None:
self.rested_since = float(now_mono)
if not self.stale:
return
if float(now_mono) - self.rested_since < self.bound.release_s:
return
self.released_at = float(now_mono)
self.releases += 1
self.stale = False
self.reason = ""
self._open_window(standing_w, now_mono)
def _grade(self, standing_w, now_mono) -> None:
"""The conjunction. Sets the latch; never clears it."""
if self.stale or self.changed_mono is None:
if self.stale or self.graded_from is None:
return
held_s = self.unchanged_s(now_mono)
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
@@ -333,30 +551,93 @@ class Freshness:
# consumer today. They are here for TEL-08, whose obligation term is an
# energy integral that has nothing to say until it has a window - and
# "nothing to say" must withhold the verdict, not manufacture one.
if standing_w is None or self.standing_at_change is None:
if standing_w is None or self.standing_at_grade is None:
return
standing_w = float(standing_w)
if abs(standing_w) < self.bound.move_w:
return
moved_w = abs(standing_w - self.standing_at_change)
moved_w = abs(standing_w - self.standing_at_grade)
if moved_w < self.bound.move_w:
return
self.stale = True
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
# and different afterwards, and an operator comparing this line against
# a history graph is reading the register.
shown_s = self.unchanged_s(now_mono)
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
# is satisfied by TWO disjoint worlds, and nothing this object is handed
# separates them:
# (1) the register has stopped reporting reality - what the gate was
# built for - or
# (2) reality is not moving, because the SOURCE cannot comply with
# the command. A pack the BMS is refusing at its floor holds
# still under any setpoint, and a register reporting that
# truthfully publishes the same unchanged number a dead one does.
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
# whole 850 s hold, the commands were discharge-only, and the register
# answered the first CHARGE command within one obligation window
# (test_freshness.py 11a measures all three off the committed capture)
# - and this sentence asserted (1), which sent the operator to look at
# healthy hardware.
# Naming both is what the module can honestly say. Naming WHICH would
# need SoC and the pack floor - deliberately not this module's inputs
# (see the `soc` row in BOUNDS and the docstring's last section), and
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
#
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
# status page and by `_read_unit()` into the action log; that one is a
# BANNER about a named ENTITY, and the two answer different questions
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
# two explanations stay, in this order, and so does the admission
# that this check cannot tell them apart. The reading is treated as
# missing either way, which the log line around this says. The
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
# logged line stays within 3 sentences.
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time, the WINDOW this
# verdict actually rests on after a release (`regraded`), and how many
# times this unit has been round the loop. Without the second one the
# sentence reads as though 92 s of unchanged register were what was
# compared against the 35 s limit, and it was not.
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
f"window since the last stop was withdrawn"
if self.releases else "")
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W - the reading is not following, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W)")
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
f"{regraded}. The reading may have stopped, or the battery may be "
f"unable to follow the command, and this check cannot tell which")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page."""
"""Seconds since an arrival carried a different number. Status page.
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
re-opens the grading window; it does not make the register younger, and
an operator reading "unchanged for 12 s" off a register that has held
one value for 850 s would be reading this module's bookkeeping back as a
measurement. `graded_s()` is the other one and is named differently for
that reason.
"""
if self.changed_mono is None:
return None
return max(0.0, float(now_mono) - self.changed_mono)
def graded_s(self, now_mono) -> float | None:
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
Equal to `unchanged_s()` until the first release, and shorter after one.
"""
if self.graded_from is None:
return None
return max(0.0, float(now_mono) - self.graded_from)
def reading(self):
"""What the consumers get: the value, or None when it cannot be trusted.
@@ -366,6 +647,13 @@ class Freshness:
rather than accumulating it, and `circulation.circulating_w` returns None
for a roster with a missing term, which clears the circulation window
too. This module is the thing that was missing between them.
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
(SAFETY-57). That is the exit, and it is bounded rather than free: the
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
consumer holds (75 s and 85 s), so neither can reach a verdict inside
one - and a re-trip hands them a None, which CLEARS their windows. See
the module docstring's last section.
"""
return None if self.stale else self.last
@@ -373,6 +661,26 @@ class Freshness:
"""One line for the status page. Empty when there is nothing to say."""
return f"{unit_name}: {self.reason}" if self.stale else ""
def release_note(self, unit_name: str) -> str:
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
"recovered" would be a lie: nothing has been learned about the register.
The verdict was withdrawn because it can no longer be tested, and the
next 35 s are the test.
"""
if self.released_at is None:
return ""
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
f"below {self.bound.move_w:.0f} W for "
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
f"verdict about a command that is no longer standing cannot be "
f"tested, and the 0 W this one produces is what stopped it "
f"being tested. The register is now re-graded over a fresh "
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
f"follow, this returns")
def monotonic() -> float:
"""The clock this module's callers use, in one place so tests can find it.
+115
View File
@@ -0,0 +1,115 @@
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
A read of `GET /api/states` through the add-on's own session (hass.states()),
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
served with its age, or an empty one that says why: the route never fails.
"""
import asyncio
import time
CACHE_S = 30.0
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
def row(state: dict) -> dict | None:
"""One state object -> {entity_id, name, state, unit, device_class}."""
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
return None
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
return {"entity_id": state["entity_id"],
"name": str(attrs.get("friendly_name") or state["entity_id"]),
"state": state.get("state"),
"unit": attrs.get("unit_of_measurement"),
"device_class": attrs.get("device_class")}
def watts(r: dict | None):
"""A power entity's reading in W, or None when it is not a number in a
power unit (the setup wizard's kettle test)."""
if not r or r.get("unit") not in _TO_W:
return None
try:
value = float(r.get("state"))
except (TypeError, ValueError):
return None
return value * _TO_W[r["unit"]] if value == value else None
def matches(r: dict, kind: str) -> bool:
"""Is `r` what a picker of this kind offers first? `kind` is one of
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
unit, cls = r.get("unit"), r.get("device_class")
if kind in ("power", "power_w"):
return cls == "power" or unit in _POWER_UNITS
if kind == "energy":
return cls == "energy" or unit in _ENERGY_UNITS
if kind == "pct":
return unit == "%"
if kind == "number":
return r["entity_id"].startswith(("number.", "input_number."))
return True
class EntityCache:
"""The last entity list read from Home Assistant, at most CACHE_S old."""
def __init__(self, hass, clock=time.monotonic):
self.hass = hass
self.clock = clock
self.rows: dict | None = None
self.read_at: float | None = None
self.tried_at: float | None = None
self._lock = asyncio.Lock()
async def get(self, max_age: float = CACHE_S
) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read.
`max_age` is how old a try may be before HA is asked again: the setup
wizard's last step asks for a list at most a few seconds old."""
async with self._lock: # one HA read however many pickers ask
now = self.clock()
# A failed read also waits CACHE_S, so polls while HA is down do
# not queue one 10 s read each behind this lock.
if self.tried_at is None or now - self.tried_at >= max_age:
self.tried_at = now
states = await self.hass.states()
if states is not None:
rows = (row(s) for s in states)
self.rows = {r["entity_id"]: r for r in rows if r}
self.read_at = now
if self.read_at is None:
return None, None
return self.rows, now - self.read_at
async def reading(self, entity_id: str) -> dict:
"""One entity read now, not from the list: the kettle test compares
two readings seconds apart, which a 30 s list cannot. A read only."""
r = row(await self.hass.state(entity_id)) if entity_id else None
return {"entity_id": entity_id, "found": r is not None,
"state": r and r["state"], "unit": r and r["unit"],
"watts": watts(r)}
def peek(self) -> dict | None:
"""The cached rows if they are fresh, without asking Home Assistant."""
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
return None
return self.rows
async def payload(self, kind: str = "") -> dict:
"""The route's JSON: matching entities first, then the rest."""
rows, age = await self.get()
if rows is None:
return {"available": False, "text": DOWN_TEXT, "entities": []}
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
if age >= CACHE_S:
out["text"] = STALE_TEXT
return out
+126 -6
View File
@@ -16,12 +16,53 @@ _LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
class Outage:
"""UX-10: Home Assistant not answering, said in one plain line per outage.
A restart takes the REST API and the meter websocket down together, and
each used to log the library's own text for every entity and service
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
not str"). Now the first channel to fail says it once at WARNING, the raw
text goes to DEBUG, and the last channel to recover says so at INFO.
"""
def __init__(self):
self.down_on = set()
def down(self, channel: str, what: str, detail) -> None:
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"restarting. The add-on keeps trying.", what)
self.down_on.add(channel)
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
def up(self, channel: str) -> None:
if channel in self.down_on:
self.down_on.discard(channel)
if not self.down_on:
_LOG.info("Home Assistant answers again.")
OUTAGE = Outage()
class ReadOnly(RuntimeError):
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
*, read_only: bool = False):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
# UX-15: setup-only mode reads Home Assistant and calls no service.
# Fixed at construction; `call()` refuses before it sends anything.
self._read_only = bool(read_only)
self._last_moan: dict[str, float] = {}
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
@@ -58,13 +99,61 @@ class HomeAssistant:
async with self.session.get(
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read {entity_id}: {resp.status}")
return None
OUTAGE.up("api")
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, TimeoutError) as err:
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
return None
# SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"read:{entity_id}", "read %s failed: %s", entity_id, err)
return None
if not isinstance(obj, dict):
self._moan(f"shape:{entity_id}", "read %s: not a state object: %.80r",
entity_id, obj)
return None
return obj
async def states(self) -> list | None:
"""Every entity's state object (`GET /api/states`), or None when Home
Assistant does not answer. A read only (UX-13 entity picker)."""
try:
async with self.session.get(
f"{CORE_API}/states", headers=self._headers,
timeout=STATES_TIMEOUT_S
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read all states: {resp.status}")
return None
OUTAGE.up("api")
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
# A timeout's own text is empty, so the line names the wait instead
# (UX-13 P1 test, c).
except TimeoutError:
self._moan("read:*", "Reading the entity list failed: no answer "
"within %s seconds.", STATES_TIMEOUT_S)
return None
except (aiohttp.ClientError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s",
str(err) or type(err).__name__)
return None
if not isinstance(obj, list):
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
@@ -100,24 +189,55 @@ class HomeAssistant:
return None
return (_f("min"), _f("max"), _f("step"))
@property
def read_only(self) -> bool:
return self._read_only
async def call(self, domain: str, service: str, data: dict) -> bool:
if self._read_only:
# Every service call, `number.set_value` included, goes through
# here, so setup-only mode has no way to command (DECISIONS UX-15).
_LOG.error("Setup-only mode called no service, because it may not "
"command anything.")
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10,
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"service {domain}.{service}: {resp.status}")
return False
OUTAGE.up("api")
if resp.status >= 400:
body = await resp.text()
# 400 here usually means out-of-range for the entity - the
# value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up
# believing something the hardware never did.
# UX-10: plain words. While Home Assistant loads its
# entities it answers 400 with only "400: Bad Request",
# which says nothing, so only a body with more is quoted.
said = body.strip()[:200]
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
""):
said = "no details"
entity = (data.get("entity_id", "no entity")
if isinstance(data, dict) else "no entity")
self._moan(f"svc:{domain}.{service}",
"service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
"Home Assistant refused %s.%s for %s (error %s, "
"%s). The entity may still be loading, or the "
"value is out of range.", domain, service,
entity, resp.status, said)
return False
return True
except (aiohttp.ClientError, TimeoutError) as err:
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection",
f"service {domain}.{service}: {err}")
return False
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
domain, service, err)
return False
File diff suppressed because it is too large Load Diff
+33 -10
View File
@@ -123,6 +123,7 @@ from datetime import datetime
from .clamp import ClampResult
from .delivery import DeliveryConfig, DeliveryWatchdog
from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
@@ -316,6 +317,18 @@ class Inverter:
tripped=bool(data.get(self.key("delivery_tripped"), False)),
reason=str(data.get(self.key("delivery_reason"), "") or ""))
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# Used at N>1 only.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -407,21 +420,31 @@ class Inverter:
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def refusal_note(self) -> str:
"""SAFETY-73's status row while this unit's battery refuses."""
if not self.refused:
return ""
return messages.row_battery_refuses(self.name, self.refused,
self.probe_from is not None)
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
"""One line for the status page. Empty when there is nothing to say.
The words are `app/messages.py`'s (UX-09). main.py colours the row red
exactly when `is_live()` is False.
"""
if not self.has_writer():
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
f"command it, and the site refuses rather than running at "
f"part strength")
live, why = self.is_live(now)
return messages.row_no_setpoint(self.name)
live, _why = self.is_live(now)
held = (0.0 if self.rejected_since is None
else (now - self.rejected_since).total_seconds())
if not live:
return f"{self.name}: NOT COMMANDABLE - {why}"
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
if self.rejected_since is not None:
held = (now - self.rejected_since).total_seconds()
return (f"{self.name}: writes rejected for {held:.0f} s - still "
f"commandable until {LINK_STALE_S:.0f} s")
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
return ""
def __repr__(self) -> str:
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -351,7 +351,7 @@ class Maintenance:
the action log rather than watching nothing happen.
"""
if blocked_by:
msg = (f"maintenance: forced start REFUSED on {self.name} - "
msg = (f"maintenance: {self.name} cannot start now, because "
f"{blocked_by}")
_LOG.warning("%s", msg)
return [msg]
@@ -378,10 +378,9 @@ class Maintenance:
"""
if self._deferred != why:
self._deferred = why
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
f"{why}. It is idle under the control loop and will "
f"recalibrate at the next start hour once the slot is free; "
f"this is not a stalled phase.")
msg = (f"maintenance: {self.name} is due and waits its turn, "
f"because {why}. It starts at the next start hour once the "
f"other battery is done. This is not a stalled phase.")
_LOG.info("%s", msg)
events.append(msg)
return events
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
"""OPT-11 P3: each battery's role this tick, from its mode and the price runs.
Pure: no I/O, no clock. main.py hands in the run plan (`priceruns`), each
battery's state and the peak guard's answers, files each battery's range as
its `mode` claim (P_MODE), and files the site trade as the `arbitrage` claim
(P_ARBITRAGE), sized inside the modes (`size()`), only with battery modes on.
With them off, `arbitrage.decide()` runs exactly as before.
Roles (the page's Why line names them):
off Off: [0, 0].
house follows the house: charges from spare solar, gives power at any
hour (Solar first always; the other modes when there is no plan).
hold Leftover solar / Cheap hours outside a give run: may charge from
spare solar, gives nothing, so its energy is kept for the run.
give the same inside a give run: covers the house.
sell inside the sell run, with selling on: gives its rating, to the
house first and the rest to the grid.
charge inside the cheap run: charges from the grid.
rest another battery charges from the grid, so this one may not charge
(the owner's 2026-09-27 answer 3: while one battery charges from the
grid the others cannot give power either; the allocator never
splits a target both ways).
Rules that keep this safe (tests: test_modes_p3.py):
* every range holds 0 W (a mode may forbid a direction, never force one);
* a Solar first battery never charges from the grid;
* P4: with a day plan (`targets`), a Leftover solar battery charges from
the grid only up to its planned level, never above the ceiling; with
none it takes solar only (P3 option A). Cheap hours only fills to the
ceiling either way (`charge_to()`);
* grid charging needs a measured peak guard that is clear and a measured
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
more, and once the guard or the headroom stops it, it stays stopped for
the rest of that 15-minute period (`blocked`): a charge makes the other
batteries stop giving power, so a few watts of charge flipping on and
off would swing the house between them and the grid (measured on the
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house);
* selling needs selling on for that battery, the sell run, a price >= 0
now, and the battery above `arbitrage_soc_floor`; it stops there. The
peak guard never stops a sell (section 9.1: discharge is never capped);
it must only have measured the quarter;
* a battery out of a run (full, or at the floor) stays out until the run
ends, so the ceiling and the floor do not re-arm within one run;
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
run in that direction: the refusal wins over the run;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST)
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
"off", "house", "hold", "give", "sell", "charge", "rest")
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# The smallest grid charge worth starting, in W. Under it the other batteries
# would stop covering the house for less than they give.
START_W = 500.0
@dataclass(frozen=True)
class Pack:
"""One battery as this module needs it."""
name: str
use: bool
mode: str
sell: bool
rating_w: float
soc: float | None
capacity_kwh: float | None
live: bool = True
idle: bool = True # not in a maintenance phase
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
@dataclass(frozen=True)
class Tick:
ranges: dict # name -> (lo, hi), only for idle batteries
roles: dict # name -> role
action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if none)
why: str | None # why no trade when a run is on, else None
def _room_kwh(p: Pack, pct: float) -> float:
"""kWh to `pct` (charging) or above it (selling); a battery with no
capacity set counts one hour at its rating, so a run is still sized."""
if p.capacity_kwh is None or p.soc is None:
return abs(p.rating_w) / 1000.0
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
def charge_to(p: Pack, ceiling_pct, targets=None):
"""The level this battery may charge to from the grid, or None.
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
when there is none (no forecast, stale, or not configured). With no plan
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
With a plan, a Leftover solar battery charges only to its planned level
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
included, gets the no-plan answer. Solar first never charges."""
if p.mode in GRID_CHARGE_MODES:
return float(ceiling_pct)
if targets is None or p.name not in targets \
or p.mode not in PLAN_CHARGE_MODES:
return None
t = targets[p.name]
return None if t is None else min(float(ceiling_pct), float(t))
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
"""Batteries that may charge from the grid in a cheap run: below the
level `charge_to()` allows them (P4: the day plan's)."""
out = []
for p in packs:
to = charge_to(p, ceiling_pct, targets)
if (to is not None and p.use and p.idle and p.live
and p.soc is not None and p.soc < to
and p.refused >= 0 and p.name not in done):
out.append(p)
return out
def sellers(packs, floor_pct, done=()) -> list:
"""Batteries that may sell in the sell run. Solar first never sells."""
return [p for p in packs
if p.use and p.idle and p.live and p.sell
and p.mode in PRICE_MODES
and p.soc is not None and p.soc > floor_pct
and p.refused <= 0 and p.name not in done]
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
targets=None):
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
With a day plan the charge is each battery's shortfall, so the cheap run
is only as long as the shortfall needs."""
ch = chargers(packs, ceiling_pct, done_charge, targets)
se = sellers(packs, floor_pct, done_sell)
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
for p in ch),
sum(abs(p.rating_w) for p in ch) / 1000.0,
sum(_room_kwh(p, floor_pct) for p in se),
sum(abs(p.rating_w) for p in se) / 1000.0)
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(),
targets=None, draining=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
`charge_ok` is True only when `TariffPlan.arbitrage()` allowed a trade
this tick with the MEASURED peak guard clear; `sell_ok` when it allowed
one with the guard measured (either state); else `trade_why` says why.
`headroom_w` is `peakguard.charge_headroom_w()` (None: unmeasured).
`house_w` is the measured house (grid + batteries), or None.
`charging_now` says last tick charged (then any headroom above 0 keeps
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may
charge from the grid (why "cycle").
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
in_give = plan_on and runs.give_at(now) is not None
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
why = None
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
else []
if ch and draining:
ch, why = [], "cycle"
charging = False
if ch:
need = 0.0 if charging_now else START_W
if blocked:
why = "headroom"
elif not charge_ok:
why = trade_why
elif headroom_w is None or not headroom_w > need:
why = "headroom"
else:
charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
else []
selling = False
if se:
if not sell_ok:
why = why or trade_why
elif price_now is None or price_now < 0.0 or house_w is None:
why = why or "negative"
else:
selling = True
ranges, roles = {}, {}
ch_names = {p.name for p in ch} if charging else set()
se_names = {p.name for p in se} if selling else set()
for p in packs:
r = abs(float(p.rating_w))
if not p.use:
role, lo, hi = OFF, 0.0, 0.0
elif p.name in ch_names:
role, lo, hi = CHARGE, -r, 0.0
elif charging:
# Another battery charges from the grid: this one may not charge
# (it would be grid charging too), and cannot give (one sign).
role, lo, hi = REST, 0.0, (r if p.mode == SOLAR_FIRST
or not plan_on or in_give else 0.0)
elif p.name in se_names:
role, lo, hi = SELL, -r, r
elif p.mode == SOLAR_FIRST or not plan_on:
role, lo, hi = HOUSE, -r, r
elif in_give:
role, lo, hi = GIVE, -r, r
else:
role, lo, hi = HOLD, -r, 0.0
roles[p.name] = role
if p.idle:
ranges[p.name] = (lo, hi)
if charging:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
return Tick(ranges, roles, None, 0.0,
why if (in_cheap or in_sell) else None)
def size(tick: Tick, bound, headroom_w) -> float:
"""The site trade in W, inside what the batteries may place with their
modes (`bound` = main's loop bound) and, for a charge, the billed
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
is never filed)."""
if tick.action is None or bound is None:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))
def weights(roles: dict, base, target_w: float):
"""Who carries the site target first (design 3.5). Selling: the sellers.
Giving power: batteries in a give run before Solar first. Charging from
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
the ranges above are the permission."""
names = sorted(roles)
first = None
if target_w > 0 and any(r == SELL for r in roles.values()):
first = {n for n in names if roles[n] == SELL}
elif target_w > 0 and any(r == GIVE for r in roles.values()):
first = {n for n in names if roles[n] in (GIVE, SELL)}
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
first = {n for n in names if roles[n] == CHARGE}
if not first:
return base
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
out = {n: (w[n] if n in first else 0.0) for n in names}
return out if any(v > 0.0 for v in out.values()) else base
+288 -16
View File
@@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down
control.
"""
import asyncio
import json
import logging
import re
try:
import paho.mqtt.client as mqtt
@@ -117,6 +119,82 @@ SENSORS = [
("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"),
]
# --- TEL-11: rows that exist ONLY when the operator configured a source ------
#
# ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS
# WHAT MAKES IT ONE. `SENSORS` is announced by every caller of
# `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that
# ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s
# fixtures, `tools/rehearse_roster.py`, anything written next year - announces
# exactly what it announced before. `p1_age` solves the same problem the other
# way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn
# when the meter is off; this one does not exist by default and appears when a
# source for it does. Getting that direction wrong is the hazard `omit`'s own
# note names: an entity that exists and is never fed is a worse signal than one
# that does not exist at all, and MOST installs will never set `pv_entity`.
#
# ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts
# the number into the recorder's long-term statistics, and a week of statistics
# is what OPT-10 needs to say whether the surplus that left this site was cloud,
# a charge-power limit, or the optimiser declining to charge.
OPTIONAL_SENSORS = [
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
]
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
# spells it. `homeassistant/components/mqtt/const.py` declares
# `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric
# branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the
# entity's state becomes `unknown` rather than a number. (Read out of
# github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor
# documentation says the same thing in its `state_topic` entry: "a 'None' value
# will set the sensor to an unknown state".)
PAYLOAD_NONE = "None"
# The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE
# DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF
# HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()`
# below drops a `None` - which is right for `circulation`, where the last figure
# standing is better than a zero that reads as health - but a DROPPED pv row
# leaves Home Assistant holding the last number it was told, forever, and the
# recorder then draws a flat line at that value through an outage. Someone
# reading it back would conclude the sun was not shining, which is precisely the
# conclusion this card exists to TEST rather than to manufacture.
ABSENT_AS_UNKNOWN = frozenset({"pv"})
def optional_keys(opts: dict) -> tuple:
"""Which `OPTIONAL_SENSORS` rows this configuration has a source for.
⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through
`MqttPublisher`) and by `Controller.publish()`. `announced_units()` above
exists for exactly this reason and says it in full: two spellings of one
predicate is how an entity gets announced that nothing feeds, or fed on a
topic nothing announced.
⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF
THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with
two spellings because both were `bool()` of one string; with `pv_entities`
beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how
the row gets announced off one key and summed off the other. ⚠️ THE ROW IS
ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity
topic, because the recorder statistic OPT-10 reads back is the site figure
and a per-inverter breakdown is a dashboard question the operator's own PV
integration already answers.
"""
# ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This
# module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py`
# part 8's mutation harness - which says so in its own comment, and whose
# honesty depends on it ("`app/mqtt.py` imports nothing from this package,
# which is what makes exec'ing it standalone honest rather than a stub").
# A module-scope `from . import pv` turns that whole instrument into an
# `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s
# `_load()` is the same decision for the same kind of reason. Measured, not
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
from . import pv as _pv
return ("pv",) if _pv.entities(opts) else ()
BASE = "goodwe_ctl"
AVAILABILITY = f"{BASE}/availability"
@@ -299,15 +377,44 @@ def _payload(name, object_id, unique_id, state_topic, device,
return cfg
def discovery_payloads(units=(), omit=()) -> list:
def unit_unique_id(slug: str, key: str) -> str:
"""One unit's entity `unique_id` (also its config topic's middle part)."""
return f"{BASE}_unit_{slug}_{key}"
def _unit_places(names):
"""`(slug, device, prefix)` for each roster name, in order."""
slugs = unit_slugs(names)
for i, (name, slug) in enumerate(zip(names, slugs)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
yield slug, device, prefix
def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
reading of the code. `_announce()` publishes exactly this list and nothing
else, which is itself checked.
`include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO
NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any
configuration - not the site rows, not the per-unit ones. Order is part of
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
ordered LIST precisely because a re-order is a zero-byte edit that
re-announces every entity on a live install).
`modes` (OPT-11 P2) is the battery modes offered, empty when they are off.
"""
omit = set(omit)
include = [k for k in include if k not in omit]
out = []
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
if key in omit:
@@ -316,19 +423,10 @@ def discovery_payloads(units=(), omit=()) -> list:
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
names = announced_units(units)
slugs = unit_slugs(names)
site_slug = _slug(DEVICE["name"])
for i, (name, slug) in enumerate(zip(names, slugs)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` below can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
for slug, device, prefix in _unit_places(announced_units(units)):
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
unique_id = f"{BASE}_unit_{slug}_{key}"
unique_id = unit_unique_id(slug, key)
out.append((
f"homeassistant/sensor/{unique_id}/config",
_payload(prefix + label,
@@ -336,12 +434,142 @@ def discovery_payloads(units=(), omit=()) -> list:
unique_id,
f"{BASE}/{unit_state_key(slug, key)}",
device, unit, dev_class, state_class, icon)))
for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS:
if key not in include:
continue
out.append((
f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon)))
# OPT-11 P2: last, and only when battery modes are on.
out.extend(mode_payloads(units, modes))
return out
# --- OPT-11 P2: each battery's switch and mode, as HA entities ---------------
# Announced only when battery modes are on, after every other row, so no
# existing row moves. A command goes through Controller.handle_action(), the
# page's /action path. DECISIONS/OPT-11-battery-modes.md has the record.
#
# The select's options are these labels, in `packmode.MODES` order. A new mode
# (P3) is a new entry here and a new option at the end; no id changes.
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar",
"cheap_hours": "Cheap hours only"} # OPT-11 P3
USE_ON, USE_OFF = "ON", "OFF"
# A longer command payload is refused before it is decoded.
MAX_COMMAND_BYTES = 64
# The store key holding the mode config topics last announced, so that turning
# modes off can remove exactly those (an empty retained config) and an install
# that never turned them on publishes nothing new.
MODE_CONFIGS_KEY = "mqtt_mode_configs"
_MODE_CONFIG_RE = re.compile(
rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config")
_COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set")
# (entity key, HA component, entity name, icon)
MODE_ENTITIES = [
("mode", "select", "Mode", "mdi:battery-sync"),
("use", "switch", "Use", "mdi:power"),
]
def mode_payloads(units, modes) -> list:
"""The `(topic, payload)` for each battery's select and switch.
Every roster entry gets them, also at a roster of one. The device rule is
TEL-07's: the first battery's rows go on the site device with its name in
front, each later battery's on its own device. Empty when `modes` is empty.
"""
if not modes:
return []
site_slug = _slug(DEVICE["name"])
options = [MODE_LABELS[m] for m in modes]
out = []
for slug, device, prefix in _unit_places([str(u) for u in units]):
for key, component, label, icon in MODE_ENTITIES:
unique_id = unit_unique_id(slug, key)
state = f"{BASE}/{unit_state_key(slug, key)}"
cfg = {
"name": prefix + label,
"object_id": f"{site_slug}_{slug}_{key}",
"unique_id": unique_id,
"state_topic": state,
"command_topic": f"{state}/set",
}
if component == "select":
cfg["options"] = options
else:
cfg["payload_on"] = USE_ON
cfg["payload_off"] = USE_OFF
# HA sends commands unretained; a retained one is ignored anyway.
cfg["retain"] = False
cfg["availability_topic"] = AVAILABILITY
cfg["device"] = device
cfg["icon"] = icon
out.append((f"homeassistant/{component}/{unique_id}/config", cfg))
return out
def mode_retractions(stored, current) -> list:
"""Config topics announced before and not now: each gets an empty
retained config, which removes the entity from HA. Anything in the store
that is not one of our mode config topics is ignored."""
if not isinstance(stored, list):
return []
keep = set(current)
return list(dict.fromkeys(t for t in stored if isinstance(t, str)
and _MODE_CONFIG_RE.fullmatch(t)
and t not in keep))
def mode_configs(units, modes, stored) -> tuple:
"""`(keep, retract)` at start-up: the mode config topics to store, and the
ones to remove now. A removal stays in `keep`, so a start with no broker
does not forget it; resending an empty config is harmless."""
current = [t for t, _ in mode_payloads(units, modes)]
retract = mode_retractions(stored, current)
return current + retract, retract
def parse_command(topic, payload, retained, modes):
"""`(slug, action, value, reject)` for one message on a command topic.
`action` is `pack_use` or `pack_mode`. `value` is what /action would be
sent: a bool for the switch, a mode key for the select, or None for a
payload that is neither (the store path then refuses it). `reject` is a
reason when the message is dropped here: replayed by the broker (retained),
too long, not UTF-8, or not a command topic.
"""
m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None
if m is None:
return None, None, None, "topic"
slug, key = m.group(1), m.group(2)
action = "pack_use" if key == "use" else "pack_mode"
if retained:
return slug, action, None, "retained"
if not isinstance(payload, (bytes, bytearray)) \
or len(payload) > MAX_COMMAND_BYTES:
return slug, action, None, "size"
try:
text = bytes(payload).decode("utf-8")
except UnicodeDecodeError:
return slug, action, None, "encoding"
if key == "use":
value = {USE_ON: True, USE_OFF: False}.get(text)
else:
value = next((k for k in modes if MODE_LABELS.get(k) == text), None)
return slug, action, value, None
class MqttPublisher:
# OPT-11 P2. Class defaults, so a publisher built without them (and every
# test fixture that builds one with __new__) behaves as before.
modes = ()
retract = ()
_loop = None
_handler = None
def __init__(self, host, port, username=None, password=None, omit=(),
units=()):
units=(), include=(), modes=(), retract=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is
@@ -353,6 +581,14 @@ class MqttPublisher:
# per-unit is announced, so every caller that has not been taught about
# the roster keeps publishing what it published before.
self.units = [str(u) for u in units]
# TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a
# second reading of the option. Defaults to empty, so a caller that has
# not been taught about it announces exactly what it announced before.
self.include = [str(k) for k in include]
# OPT-11 P2: the modes offered (empty when battery modes are off), and
# the mode config topics to remove (`mode_retractions()`).
self.modes = tuple(modes)
self.retract = [str(t) for t in retract]
self.enabled = mqtt is not None and bool(host)
self.client = None
if not self.enabled:
@@ -375,7 +611,8 @@ class MqttPublisher:
# paho DROPS QoS-0 publishes when disconnected, silently. The result is
# an add-on that logs "MQTT connected" and creates no entities at all.
# As a bonus, this also re-announces after every reconnect.
self.client.on_connect = lambda *_args, **_kw: self._announce()
self.client.on_connect = lambda *_args, **_kw: self._on_connect()
self.client.on_message = self._on_message
try:
self.client.connect(host, int(port), keepalive=60)
self.client.loop_start()
@@ -389,17 +626,52 @@ class MqttPublisher:
# announcement, so the check that diffs it against `release/1.0` is
# diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit):
for topic, cfg in discovery_payloads(self.units, self.omit,
self.include, self.modes):
self.client.publish(topic, json.dumps(cfg), retain=True)
for topic in self.retract:
self.client.publish(topic, "", retain=True)
self.client.publish(AVAILABILITY, "online", retain=True)
def _on_connect(self) -> None:
self._announce()
if self.modes:
for key in ("mode", "use"):
self.client.subscribe(f"{BASE}/unit/+/{key}/set")
def bind(self, loop, handler) -> None:
"""OPT-11 P2: hand each command to `handler(topic, payload, retained)`,
a coroutine function run on `loop` (the controller's)."""
self._loop, self._handler = loop, handler
def _on_message(self, _client, _userdata, msg) -> None:
# paho's network thread: nothing is done here but the handoff.
loop, handler = self._loop, self._handler
if loop is None or handler is None:
_LOG.debug("MQTT command before start-up ignored: %s", msg.topic)
return
try:
asyncio.run_coroutine_threadsafe(
handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop)
except RuntimeError as err: # the loop has closed
_LOG.debug("MQTT command dropped: %s", err)
def publish(self, values: dict) -> None:
if not self.enabled or self.client is None:
return
try:
for key, value in values.items():
if value is None:
continue
# ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping
# leaves Home Assistant holding the last value it was told,
# which is what `circulation` wants (its note in main.py
# says why) and is exactly wrong for a measurement somebody
# will read back as history: a flat line at the last number
# is indistinguishable from a real one. See
# `ABSENT_AS_UNKNOWN`.
if key not in ABSENT_AS_UNKNOWN:
continue
value = PAYLOAD_NONE
self.client.publish(f"{BASE}/{key}", str(value))
except OSError as err: # pragma: no cover
_LOG.debug("MQTT publish failed: %s", err)
+54 -31
View File
@@ -47,6 +47,11 @@ from datetime import datetime, timezone
import aiohttp
from . import messages
from .hass import OUTAGE
_S = messages.setting # a refusal names the setting by its label (UX-08)
try:
import paho.mqtt.client as mqtt
except ImportError: # pragma: no cover - container always has it
@@ -180,7 +185,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
# already applied a sign we are about to apply again - reject it rather
# than silently double-signing the control loop.
if imp < 0 or exp < 0:
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
raise P1Error(f"import and export readings cannot be negative "
f"(import={imp:g} export={exp:g})")
per_phase = per_phase_import = None
if phase_import_w is not None or phase_export_w is not None:
@@ -188,8 +194,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
pe = list(phase_export_w or [0.0] * len(pi))
if len(pi) != phases or len(pe) != phases:
raise P1Error(
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
f"phases, meter_phases is {phases}")
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
f"but {_S('meter_phases')} is {phases}")
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
for i, (a, b) in enumerate(zip(pi, pe))]
per_phase = tuple(vals)
@@ -467,7 +473,7 @@ class P1Ingest:
"""
self.parse_errors += 1
self.last_error = str(err)
_LOG.warning("P1 telegram rejected: %s", err)
_LOG.warning("A meter message could not be read: %s", err)
# -- what consumers read -------------------------------------------------
def age_s(self) -> float | None:
@@ -519,6 +525,16 @@ WS_URL = "ws://supervisor/core/websocket"
BAD_STATES = ("unknown", "unavailable", "none", "")
async def _receive_json(ws):
"""The next JSON message. A socket Home Assistant closed (a restart) is a
ConnectionResetError, not aiohttp's "Received message 8:1000 is not str"."""
msg = await ws.receive()
if msg.type is not aiohttp.WSMsgType.TEXT:
raise ConnectionResetError(
f"Home Assistant closed the connection ({msg.type.name})")
return json.loads(msg.data)
class HaDsmrSource:
"""Subscribes to state_changed for the configured DSMR entities.
@@ -569,8 +585,12 @@ class HaDsmrSource:
backoff = 1.0
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, ConnectionError) as err:
# UX-10: Home Assistant restarting, said once (hass.Outage).
OUTAGE.down("websocket", "the meter connection closed", err)
except Exception as err: # noqa: BLE001 - any transport fault retries
_LOG.warning("P1 HA websocket: %s - reconnecting in %.0fs", err, backoff)
_LOG.warning("The meter connection to Home Assistant failed "
"(%s), trying again in %.0f s.", err, backoff)
finally:
self.connected = False
await asyncio.sleep(backoff)
@@ -578,16 +598,17 @@ class HaDsmrSource:
async def _session_once(self) -> None:
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws:
hello = await ws.receive_json()
hello = await _receive_json(ws)
if hello.get("type") == "auth_required":
await ws.send_json({"type": "auth", "access_token": self.token})
reply = await ws.receive_json()
reply = await _receive_json(ws)
if reply.get("type") != "auth_ok":
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
await ws.send_json({"id": 1, "type": "subscribe_events",
"event_type": "state_changed"})
await ws.send_json({"id": 2, "type": "get_states"})
self.connected = True
OUTAGE.up("websocket")
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
async for msg in ws:
@@ -613,7 +634,7 @@ class HaDsmrSource:
new = data.get("new_state") or {}
self._absorb(data.get("entity_id"), new.get("state"))
self._schedule()
raise RuntimeError("websocket closed")
raise ConnectionResetError("Home Assistant closed the connection")
def _absorb(self, entity_id: str | None, state) -> None:
if not entity_id or entity_id not in self.ids:
@@ -1160,37 +1181,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
exp = str(opts.get("p1_export_entity", "") or "").strip()
if not imp or not exp:
problems.append(
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
if not net:
problems.append(
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
"(sensor.p1_sample_age_s would otherwise be announced with "
"nothing feeding it)")
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
f"{_S('p1_net_entity')}.")
if phase_net and len(phase_net) != phases:
problems.append(
f"p1_phase_net_entities has {len(phase_net)} entities but "
f"meter_phases is {phases} - every telegram would be rejected "
"on the phase-count check")
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
f"but {_S('meter_phases')} is {phases}. Make them match.")
elif source == SOURCE_MQTT:
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
f"needs {_S('meter_mqtt_topic')}.")
elif source == SOURCE_HOMEWIZARD:
host, port_s = homewizard_host_port(opts)
if not host:
problems.append(
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
"own address, e.g. 192.168.2.250)")
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
f"{_S('p1_host')}, the meter's address on your network.")
if port_s:
try:
int(port_s)
except ValueError:
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
"unparseable port")
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
"its port is not a number.")
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
# cadence of 0 is a misconfiguration that must be reported, not
@@ -1198,23 +1219,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
raw = opts.get("meter_poll_s", 5)
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
problems.append(f"{_S('meter_poll_s')} is "
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
"number.")
else:
if poll_s <= 0:
problems.append("meter_poll_s must be positive")
problems.append(f"{_S('meter_poll_s')} must be above 0.")
# ⚠️ The age can never be fresher than the poll interval. At or past
# max_age_s every reading is stale before its successor arrives, so
# the controller would sit permanently on missing inputs while the
# meter is perfectly healthy - refuse it rather than ship that.
elif poll_s >= max_age_s:
problems.append(
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
f"{max_age_s:.1f} - every reading would go stale before the "
"next poll")
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
"often, or every reading goes stale.")
else:
problems.append(
f"meter_source {source!r} is not one of "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
+ ".")
return problems
@@ -1261,9 +1285,8 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
# can NEVER be fresh.
if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
"%.1f: one missed poll makes the reading stale. The meter "
"updates every ~5 s; 5 s against 30 s is the tested pair.",
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT:
+129
View File
@@ -0,0 +1,129 @@
"""OPT-11: each battery's mode and on/off switch, as limits and weights.
Pure: no I/O, no clock. main.py reads the store, calls these, and files the
result as a `mode` claim (arbiter.P_MODE) and as the charge weights.
DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has
the modes.
Two rules hold for every mode, and they are what keeps modes safe:
1. A mode range always holds 0 W. A mode may forbid a direction, never force
one, so batteries never move power in opposite directions because of a mode
(allocator.unit_interval() drops a range without 0 as a software fault).
2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out:
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
and weights only set the order among batteries that may.
`mode_range()` is the switch alone (Off is [0, 0]). P3 adds the price runs:
`modetrade.decide()` narrows each battery's range by its role this tick
(hold for the expensive hours, charge from the grid, sell), and main.py files
the intersection. Selling is a per-battery choice (`pack_sell`, the owner's
2026-09-27 answer), for Leftover solar and Cheap hours only.
"""
from dataclasses import dataclass
from .store import unit_key
SOLAR_FIRST = "solar_first"
LEFTOVER_SOLAR = "leftover_solar"
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
# The modes a battery may be set to, in the order the page and the HA select
# list them. The first is the default when the store has no value. A new mode
# is appended, never inserted: automations send the HA labels (P2).
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR, CHEAP_HOURS)
DEFAULT_MODE = SOLAR_FIRST
# Store keys, per battery: `unit/<name>/<key>` (store.unit_key()).
USE_KEY = "pack_use"
MODE_KEY = "pack_mode"
SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
# Owner decision 2026-09-29 (option A): selling is off until it can be judged
# on the price the grid pays. While False, `read()` answers `sell=False`
# whatever the store holds, the page shows no switch and `pack_sell` is
# refused; the sell code in modetrade/priceruns stays, unreachable.
SELLING_ENABLED = False
# The modes that may charge from the grid in a cheap run with no day plan.
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
GRID_CHARGE_MODES = (CHEAP_HOURS,)
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
def enabled(opts) -> bool:
"""Battery modes are on only when the option is exactly true. One answer,
read by the controller and by the MQTT announcement."""
return opts.get("battery_modes_enabled") is True
@dataclass(frozen=True)
class PackMode:
"""One battery's choice: on or off, its mode, and selling."""
use: bool = True
mode: str = DEFAULT_MODE
sell: bool = False
@property
def can_sell(self) -> bool:
"""Selling applies to the price modes only; Solar first never sells.
False for every battery while SELLING_ENABLED is False."""
return SELLING_ENABLED and self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
@property
def key(self) -> str:
"""The page's word for this state: a mode, or `off`."""
return self.mode if self.use else "off"
def read(data: dict, name: str) -> PackMode:
"""Battery `name`'s choice from the store's data. A missing or unknown
value falls back to the default (on, Solar first), never to Off: a store
written by a later version must not switch a battery off here."""
use = data.get(unit_key(name, USE_KEY), True)
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
return PackMode(use=use is not False,
mode=mode if mode in MODES else DEFAULT_MODE,
sell=SELLING_ENABLED
and data.get(unit_key(name, SELL_KEY)) is True)
def mode_range(pm: PackMode, rating_w: float) -> tuple:
"""`(lo, hi)` in W for this battery's switch. Always holds 0 W.
Off asks for 0 W both ways; On allows the rating both ways. main.py files
this intersected with the role range from `modetrade.decide()`.
"""
if not pm.use:
return 0.0, 0.0
r = abs(float(rating_w))
return -r, r
def charge_weights(base, modes: dict, target_w: float):
"""The allocator's weights with the solar order applied (design 3.5).
`base` is packshare.weights()' answer (None for "all 1.0"). While the site
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
first battery keeps its weight, so the fill gives the Solar first batteries
everything they can take before the last-resort pass reaches the others.
Giving power keeps `base`; `modetrade.weights()` adds the run order.
When no battery that is on would keep a positive weight (all Leftover
solar, or every Solar first one at 0), `base` is returned unchanged, so the
order among equals stays OPT-09's rather than an equal split.
"""
if float(target_w) >= 0.0 or not modes:
return base
names = sorted(modes)
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
out = {n: (0.0 if modes[n].mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
else w[n])
for n in names}
if not any(out[n] > 0.0 for n in names if modes[n].use):
return base
return out
+124 -46
View File
@@ -143,6 +143,10 @@ because the money is spent on `max(month's peak so far, this quarter)`:
ceiling_w = max(peak_cap_w, the month's banked peak)
SAFETY-79: the guard AIMS `peak_margin_w` under that ceiling
(`quarter_target_w()`), so a quarter that lands a little high still stays under
it. `ceiling_w` stays the real limit for reports and the bill.
Once a month's peak already stands at 5013 W, holding every later quarter to
3500 W buys nothing - the bill for that month is already 5013 W - while the
holding itself costs real charging the site wanted. Raising the ceiling to a peak
@@ -395,33 +399,34 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page
# and the action log both have to say WHICH, and "blind" alone is unactionable.
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
BLIND_FROZEN = "the meter is answering without changing"
# Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
CEILING_MONTH_PEAK = "this month's peak"
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
# for BLIND_*'s reason: the status page, the action log and the operator
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
"cycle, and an unreadable SoC is not a full battery")
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
# possessive is load-bearing: this string is what the status page, the action log
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
# reads at the moment the battery stops shaving into a claim about the pack that
# the grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
"unread is not full")
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
# load-bearing: this string is what the action log and the add-on log print,
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
# "the SoC floor" would make the one sentence an operator reads at the moment
# the battery stops shaving into a claim about the pack that the
# grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak"
@@ -452,7 +457,13 @@ PEAK_OPTION_DEFAULTS = {
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
"bms_soc_floor": 10,
# SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that
# lands a little high still stays under the cap. Owner decision 2026-09-28.
"peak_margin_w": 50,
}
# SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so
# validate.py can refuse it in words; test_peakguard.py pins it to the file.
PEAK_MARGIN_RANGE_W = (0, 500)
@dataclass(frozen=True)
@@ -473,6 +484,7 @@ class PeakDecision:
projected_w: float | None = None
excess_w: float = 0.0
wanted_w: float | None = None # the floor before `achievable_w` clamped it
target_w: float | None = None # SAFETY-79: what the guard aims at
saturated: bool = False
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
# floor refused to size it. Mutually exclusive with `saturated` by
@@ -485,17 +497,29 @@ class PeakDecision:
reason: str = ""
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return messages.row_peak_guard_quiet()
return messages.row_peak_guard_idle(
self.projected_w, self.ceiling_w,
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason
def blind_kind(blind: str) -> str:
"""Which of the three kinds of blindness, as a key of
`messages.BLIND_WORDS`, for the status row."""
if blind == BLIND_FROZEN:
return "frozen"
if blind == BLIND_NO_BLOCK:
return "no_block"
return "gap"
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
@@ -511,6 +535,15 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
return cap, CEILING_CAP
def quarter_target_w(cap_w, margin_w, reading) -> float:
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
`quarter_ceiling_w()`, so under a month peak already banked as well as
under the cap. Aiming AT a banked peak can bank a few W over it, which
raises the bill as surely as going over the cap. Reports and the bill keep
the real limit (`ceiling_w`)."""
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
def excess_w(projection, ceiling_w) -> float:
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
@@ -553,8 +586,9 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return BLIND_NO_BLOCK
gap = uncovered_s(projection)
if max_hold_s is not None and gap > float(max_hold_s):
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
f"covered, past the {float(max_hold_s):.0f} s a reading is "
f"held")
return None
@@ -699,8 +733,8 @@ def binding_soc_floor(packs, soc_floor):
return max(floors)
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
`batt_w` MEASURED battery power, positive = discharge. None when the
@@ -718,8 +752,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
the pack for money. REQUIRED, and deliberately without a
default: a default here would be CFG-03's fourth defaults
site and TEST-14's quiet fallback in one line.
`margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`).
REQUIRED for the same reason. `ceiling_w` stays the real
limit for every report; only `excess_w` uses the target.
"""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
target = quarter_target_w(cap_w, margin_w, reading)
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
ceil_top = abs(float(achievable_w))
blind = blindness(projection, max_hold_s, frozen)
@@ -729,10 +768,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# see", which is one-directional and cannot cost pack energy.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None,
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
f"while the billed quarter cannot be measured; commanding no "
f"charging (floor 0 W)"))
target_w=target, blind=blind, projected_w=None,
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
f"while this 15-minute period cannot be measured, so it "
f"is asked for no charging, floor 0 W"))
projected = float(projection.projected_w)
if batt_w is None:
@@ -741,7 +780,7 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# measurement that is not there is a made-up number.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
ceiling_from=from_where, target_w=target, projected_w=projected,
reason="no battery measurement to size a floor from")
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
# A block with no seconds left cannot be influenced. This is degenerate
@@ -749,10 +788,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# the battery's own current output would be a claim that says nothing.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
ceiling_from=from_where, target_w=target, projected_w=projected,
reason="the quarter has no seconds left to influence")
ex = excess_w(projection, ceiling)
ex = excess_w(projection, target)
wanted = float(batt_w) + ex
if wanted <= 0.0:
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
@@ -773,7 +812,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# the quantity being thresholded is simply the right one.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
@@ -791,35 +831,73 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# not the grid-following loop, which still has no SoC guard.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
# UX-10: plain sentences, every figure kept (test_messages.py
# part 6). The code is one token, so it is not read as shouting.
reason=(
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
f"it, but {denied}"
+ ("" if soc is None else f" ({float(soc):.0f} %")
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
f"{projected:.0f} W against a {ceiling:.0f} W limit "
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
f"would hold it. But {denied}"
+ ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" vs {float(soc_floor):.0f} %")
+ ("" if soc is None else ")")
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
else f" against {float(soc_floor):.0f} %")
+ ", so no forced discharge and no charging, and this period "
"will go over the limit"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated, soc=soc, soc_floor=soc_floor,
target_w=target, blind=None, projected_w=projected, excess_w=ex,
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
f"{ceiling:.0f} W ceiling ({from_where}) with "
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
f"floor {floor:+.0f} W"
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
f"only give {ceil_top:.0f} W" if saturated else "")))
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"and is asked for {floor:+.0f} W"
# Saturated means floor == ceil_top: the ask IS the most the
# inverters give, so one clause says both.
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
if saturated else "")))
# SAFETY-81. A quarter must end this many W over the real limit before
# "Load too high for battery" fires. Above the guard's own landing error at
# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts.
ALARM_MISS_W = 10.0
# ...and for this long without a break, so a load that peaks for a few seconds
# (a hob or heater cycling) is not read as the rest of the quarter.
ALARM_HOLD_S = 10.0
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the
battery at full power from now on, the quarter still ends more than
`ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim).
This is the miss itself, in W of the quarter average, so it does not have
`decide()`'s gain (900 / remaining): near a quarter's end that gain turns
a watt over the aim into thousands of W wanted.
The load is the held meter reading plus `batt_w`. The caller passes the
lower of this cycle's and the last cycle's battery reading, so a meter
sample taken before the battery's last increase is not counted twice
(DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter
before the battery moves, so that filter costs it no cycle.
"""
remaining = QUARTER_S - float(projection.elapsed_s)
if batt_w is None or remaining <= 0.0:
return False
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
done_ws = (QUARTER_S * float(projection.projected_w)
- max(float(projection.held_w), 0.0) * remaining)
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
return ends_w - float(ceiling_w) > ALARM_MISS_W
def charge_headroom_w(decision) -> float | None:
+35 -44
View File
@@ -150,6 +150,7 @@ from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
from . import messages
from .custom_formula import (
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
@@ -1314,8 +1315,8 @@ def code_leaks(cls) -> list:
src = dedent(inspect.getsource(cls))
tree = ast.parse(src)
except (OSError, TypeError, IndentationError, SyntaxError) as e:
_LOG.warning("could not read %s's source for the framework-key scan "
"(%s); the double-counting guard is NOT enforced for it",
_LOG.warning("Could not read the code of price source %s (%s), so its "
"double-counting check is skipped.",
getattr(cls, "id", cls), e)
return []
for node in ast.walk(tree):
@@ -1453,9 +1454,8 @@ def _validate_raw(points, caps):
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
raise SourceUnavailable(
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed "
f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 "
f"and would make every hour look like an emergency")
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH}, so the feed is probably in "
f"EUR/MWh")
u = p.start.astimezone(timezone.utc)
if seen is not None and u <= seen:
raise SourceUnavailable(
@@ -1708,25 +1708,19 @@ def _points_from_columnar_json(body, expect_unit, scale,
if len(times) != len(prices):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
f"The short array would truncate the series silently, and a price "
f"series missing its last hours is one missing the evening peak - "
f"traded on as if complete, where no data at all would have "
f"switched arbitrage off (§11.4)")
f"{COLUMNAR_PRICE_KEY} has {len(prices)}, so the feed is cut short "
f"and none of it is used")
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
if unit != _columnar_unit(expect_unit):
raise SourceUnavailable(
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
f"{expect_unit!r}. This provider converts with a fixed factor of "
f"{scale}, so a changed unit is a factor of 1000 on every price "
f"with nothing downstream able to notice it")
f"{expect_unit!r}, so its prices cannot be converted")
if doc.get("deprecated"):
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
_LOG.warning("The price feed says it will stop (deprecated=%r), "
"though it still answers for now.", doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
@@ -1745,12 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("the feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
len(dropped), len(times),
_LOG.warning("The price feed gave no price for %d of %s, at index "
"%s. Those are left out, not counted as free.",
len(dropped), messages.count(len(times), "time"),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
+ (" and more" if len(dropped) > 5 else ""))
if not out:
raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of "
@@ -1796,16 +1789,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
"price_feed_url is set, so %s asks for %s as given, and "
"tomorrow's prices may stay provisional. Clear it to ask for %s "
"to %s.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
_LOG.debug(
"The public feed answers one Brussels day to a request that names "
"no range. Every later hour is then copied from yesterday and "
"marked provisional, and §8.3 leaves provisional hours out of "
"trading.")
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
@@ -2011,11 +2004,9 @@ class CustomFormula(PriceProvider):
raise SourceUnavailable(f"price_formula: {e}")
if spec is None:
raise SourceUnavailable(
"price_provider is 'custom_formula' but price_formula is "
"empty, so there are no coefficients to price anything with. "
"validate.py refuses this configuration at startup; reaching "
"it here means the options blob changed under a running "
"add-on")
"“Dynamic price source” (price_provider) is 'custom_formula', "
"but “Your own tariff formula” (price_formula) is empty. "
"Restart the add-on to check the settings again")
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
@@ -2091,8 +2082,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
"are not affected.",
pid, type(e).__name__, e)
return
for line in lines:
@@ -2116,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
pid = provider_id(opts)
if not provider_configured(opts):
return PriceRead.no_data(
"no price provider configured (price_provider is 'off'); "
"§11.4: static profile, plan provisional, no arbitrage")
"no price feed is set (price_provider is 'off'), so the fixed "
"tariff is used, the plan is estimated and there is no trading")
cls = PROVIDERS.get(pid)
if cls is None:
return PriceRead.no_data(
f"unknown price provider {pid!r}; shipped: "
f"unknown price provider {pid!r}. The known ones are "
f"{', '.join(sorted(PROVIDERS))}")
caps = cls.capabilities()
@@ -2139,12 +2130,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
# Not silent, and not a traceback into the 30 s tick. validate.py
# should have refused this at startup; if the add-on is running with it
# anyway, the operator is told which key and what is wrong with it.
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
"has NO tax component to add - it is not falling back to "
"price_taxes_eur_kwh, because a silently different tax "
"basis is the defect this option exists to remove. Fix the "
"table or clear it; validate.py refuses it at startup",
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
"are added to this price update. Fix the table or clear it.",
type(e).__name__, e)
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
"silently different tax basis is the fault this setting "
"exists to remove.")
return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}")
if table is not None:
+27 -24
View File
@@ -164,6 +164,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from . import messages
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
f"fall outside {messages.when(brussels(since))} to "
f"{messages.when(brussels(until))}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
f"no hours could be filled from yesterday's prices: {reason}. So "
f"the fixed tariff is used, the plan is estimated and there is no "
f"trading")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
# feed's is the POINT; nothing derived from one answers for the other.
total_h = len({_utc_hour(p.start) for p in out})
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; the feed published %d "
"points over the other %d hours",
carried, total_h, len(out) - carried, total_h - carried)
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
"The feed published %s over the other %s.",
carried, messages.count(total_h, "hour"),
messages.count(len(out) - carried, "price"),
messages.count(total_h - carried, "hour"))
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
@@ -525,8 +528,8 @@ class _Permitted(Gate):
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
f"from {messages.when(brussels(pts[0].start))}")
class _Blocked(Gate):
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
f"{action.name} needs a price and there is none: {r.reason}. A "
f"missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and no price "
f"covers {messages.when(brussels(u))}. An hour with no price "
f"is not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and the price for "
f"{messages.when(brussels(u))} is only estimated. It can start "
f"once the day-ahead prices are published, around 14:00 to "
f"16:00")
pts.append(p)
return Gate.permit(action, pts)
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
_LOG.warning("An action that cannot be undone does not start: %s.",
gate.reason)
return gate
+57 -40
View File
@@ -186,8 +186,9 @@ import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from . import messages
from .price import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday
@@ -345,8 +346,19 @@ def _row_of(day: str, points: int, hours: int) -> str:
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} point{'' if points == 1 else 's'} "
f"over {hours} h)")
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
def day_words(day) -> str:
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
not a date is shown as it is, because this feeds a status row."""
try:
d = date.fromisoformat(str(day))
except ValueError:
return str(day)
return f"{d.day} {d:%b}"
def _bad_rows(rec) -> int:
@@ -393,9 +405,8 @@ class PriceHistory:
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
"to carry and the horizon stays as short as the feed",
self.last_error)
_LOG.info("No price history is kept (%s), so missing hours are "
"not filled from yesterday's prices.", self.last_error)
return
self.ok = True
self._load()
@@ -437,9 +448,9 @@ class PriceHistory:
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
_LOG.warning("Price history at %s is %s, so it starts empty. "
"Missing hours cannot be filled in until a full day "
"is saved.",
self.path, self.last_error)
return
found = blob.get("version") if isinstance(blob, dict) else None
@@ -449,15 +460,15 @@ class PriceHistory:
# would not notice a writer this build has never met.
if not isinstance(blob, dict) or isinstance(found, bool) \
or not isinstance(found, int) or found != SCHEMA:
self.last_error = f"schema {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather "
"than guessing what its points mean",
self.last_error = f"version {found!r}, not {SCHEMA}"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
days = blob.get("days")
if not isinstance(days, dict):
self.last_error = "no `days` object"
_LOG.warning("price history at %s has %s", self.path, self.last_error)
self.last_error = "no days in it"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
self._days = self._usable_days(days)
@@ -504,20 +515,22 @@ class PriceHistory:
kept[key] = rec
notes = []
if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
f"dropped, " + ", ".join(day_words(d)
for d in sorted(dropped)))
if malformed:
notes.append(
f"{sum(malformed.values())} malformed point row(s) in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
f"{messages.count(sum(malformed.values()), 'bad price')} in "
+ ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(malformed.items())))
if notes:
# Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day.
self.last_error = "; ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts "
"anyway and the horizon is as short as the feed makes "
"it", self.path, self.last_error)
self.last_error = " and ".join(notes)
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
"and fills in only what the rest holds.",
self.path, self.last_error)
return kept
def _save(self) -> bool:
@@ -680,10 +693,11 @@ class PriceHistory:
# undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
_LOG.warning("price history at %s: %s - the record is rewritten "
"without them and the refresh continues",
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
f"from " + ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(junked.items())))
_LOG.warning("Price history at %s: %s. The file is saved without "
"them and the price update goes on.",
self.path, self.last_error)
self._prune(stamp)
if not self._save():
@@ -752,21 +766,23 @@ class PriceHistory:
if self.last_error and not self._days:
return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so "
f"§8.3 has no profile to carry forward for {want}; the plan "
f"covers only the hours the feed published")
f"there are no prices from yesterday to fill in for "
f"{day_words(want)}. The plan covers only the hours the feed "
f"published")
rec = self._days.get(want)
if rec is None:
have = ", ".join(sorted(self._days)) or "no days at all"
have = (", ".join(day_words(d) for d in sorted(self._days))
or "no days at all")
return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so "
f"§8.3 has no profile to carry forward. A record for another "
f"day is not a yesterday")
f"no published prices are kept for {day_words(want)}, only for "
f"{have}, so there are no prices from yesterday to fill in. A "
f"record for another day is not a yesterday")
points, bad = _parse_points(rec.get("points"))
if not points:
why = bad or "it is empty"
return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an "
f"empty series is not a free day")
f"the {day_words(want)} record holds no usable price, {why}, "
f"and an empty series is not a free day")
try:
product = PriceProduct(rec.get("product"))
except ValueError:
@@ -775,8 +791,9 @@ class PriceHistory:
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("price history: %s in the %s record were dropped; "
"%d point(s) carried forward", bad, want, len(points))
_LOG.warning("Price history: %s dropped from the %s record, and "
"%s kept.", bad, day_words(want),
messages.count(len(points), "price"))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
@@ -805,7 +822,7 @@ class PriceHistory:
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
held = "; ".join(_row_of(d, *_held(self._days[d]))
held = ", ".join(_row_of(d, *_held(self._days[d]))
for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written.
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
continue
out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
class NoHistory:
@@ -861,5 +878,5 @@ class NoHistory:
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no "
"profile to carry forward")
"this caller keeps no price history, so there are no prices from "
"yesterday to fill in")
+203
View File
@@ -0,0 +1,203 @@
"""OPT-11 P3: price runs. Which consecutive firm price slots are cheap, which
are expensive, and which one a battery may sell in.
Pure: prices, the time and a few energy figures in, a `RunPlan` out. No I/O,
no clock. `modetrade.py` turns the plan into each battery's range and the
site's trade; main.py rebuilds the plan at most once per 15 minutes and keeps
a run that has started until its end (`keep_started()`).
The owner's SAFETY-46 answer (2026-09-25): a trade is a run of consecutive
slots, not one 15-minute interval. OPT-11-design section 2 ("Prices") and
DECISIONS/OPT-11-battery-modes.md have the record.
* The CHEAP run is the cheapest consecutive firm window long enough to put
the planned energy in at the chargers' combined rating.
* A slot is EXPENSIVE when its price is above the cheap run's mean by at
least the required spread (`cycle_cost_threshold_eur`, widened as
`TariffPlan.arbitrage()` widens it). Give runs are the maximal consecutive
expensive slots. Holding batteries cover the house in them. The mean is
the lower of the next cheap run's and `ref_eur_kwh`, the cheap run the
batteries last filled in (main.py keeps it for 12 h): energy bought at
03:00 is still worth that price at 08:00, when the next cheap run in sight
is today's dearer midday (measured: without it a Cheap hours battery spent
its night charge on flat morning prices, then had nothing at 19:00).
* The SELL run is the dearest consecutive window inside one give run long
enough to sell the sellers' energy at their rating, with no slot below 0
(section 17 of the spec: never export at a negative price).
* A provisional (carried) price is never part of any run (section 8.3).
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from .price import _cover_table
# How far ahead runs are looked for. A day: tonight's cheap hours and the next
# evening's expensive ones, without holding a battery for a peak two days out.
HORIZON = timedelta(hours=24)
# No plan, and why (the page words are in homeview / ui_text).
NO_PRICES = "no_prices" # fewer than two firm slots ahead
NOT_WORTH = "not_worth" # no slot clears the required spread
@dataclass(frozen=True)
class Run:
start: datetime
end: datetime
mean_eur_kwh: float
def holds(self, when: datetime) -> bool:
return self.start <= when < self.end
@dataclass(frozen=True)
class RunPlan:
"""What the prices say for the next day. `reason` is None when there is
a plan (a cheap run and at least one give run), else why there is none."""
cheap: Run | None
give: tuple
sell: Run | None
level_eur_kwh: float | None
reason: str | None
# A slot after the cheap run clears the spread over the CHEAP RUN'S OWN
# price (not the reference): only then is buying in it worth a cycle.
charge_worth: bool = False
@property
def on(self) -> bool:
return self.reason is None
def give_at(self, when):
return next((g for g in self.give if g.holds(when)), None)
def next_give(self, when):
"""The give run under way at `when`, else the next one, else None."""
return next((g for g in self.give if g.end > when), None)
def charge_useful(self) -> bool:
"""Charging in the cheap run pays only if, after it, a slot clears
the spread over the cheap run's own price."""
return self.cheap is not None and self.charge_worth
def firm_slots(points, now: datetime, horizon: timedelta = HORIZON) -> list:
"""`[(start, end, eur_kwh)]`, ascending: the firm slots that have not
ended and start within the horizon. A slot's span is TARIFF-14's rule
(`price._cover_table()`), so 15-minute and hourly series both work."""
out = []
for start, end, p in _cover_table(points):
if p.provisional or end <= now or start >= now + horizon:
continue
if end > start:
out.append((start, end, float(p.eur_per_kwh)))
return out
def _segments(slots) -> list:
"""Split slots into runs of consecutive ones (each starts where the last
ended). A gap or a provisional slot breaks a run."""
segs, cur = [], []
for s in slots:
if cur and s[0] != cur[-1][1]:
segs.append(cur)
cur = []
cur.append(s)
if cur:
segs.append(cur)
return segs
def _hours(s) -> float:
return (s[1] - s[0]).total_seconds() / 3600.0
def _mean(win) -> float:
h = sum(_hours(s) for s in win)
return sum(_hours(s) * s[2] for s in win) / h
def _windows(segs, need_h):
"""Every consecutive window that lasts at least `need_h` hours, shortest
such from each start. If no segment is that long, each whole segment."""
out = []
for seg in segs:
for i in range(len(seg)):
h, j = 0.0, i
while j < len(seg) and h < need_h - 1e-9:
h += _hours(seg[j])
j += 1
if h >= need_h - 1e-9:
out.append(seg[i:j])
return out or [seg for seg in segs]
def need_hours(kwh, kw, slots) -> float:
"""How long a run must last: `kwh` at `kw`, and at least one slot."""
one = min((_hours(s) for s in slots), default=1.0)
if not (kwh and kw) or kwh <= 0 or kw <= 0:
return one
return max(one, float(kwh) / float(kw))
def plan(points, now: datetime, *, required_eur_kwh: float, charge_kwh,
charge_kw, sell_kwh, sell_kw, horizon: timedelta = HORIZON,
ref_eur_kwh=None) -> RunPlan:
"""The run plan for the next `horizon`. Ties go to the earliest window."""
if not (isinstance(required_eur_kwh, (int, float))
and math.isfinite(required_eur_kwh) and required_eur_kwh >= 0):
raise ValueError("required_eur_kwh must be a finite number >= 0")
slots = firm_slots(points, now, horizon)
if len(slots) < 2:
return RunPlan(None, (), None, None, NO_PRICES)
segs = _segments(slots)
wins = _windows(segs, need_hours(charge_kwh, charge_kw, slots))
best = min(wins, key=lambda w: (round(_mean(w), 9), w[0][0]))
cheap = Run(best[0][0], best[-1][1], _mean(best))
base = cheap.mean_eur_kwh if ref_eur_kwh is None else min(
cheap.mean_eur_kwh, float(ref_eur_kwh))
level = base + float(required_eur_kwh)
# Expensive: clears the spread, strictly dearer than the cheap mean, and
# not part of the cheap run itself (a threshold of 0 must not overlap).
dear = [s for s in slots
if s[2] >= level and s[2] > cheap.mean_eur_kwh
and not (cheap.start <= s[0] < cheap.end)]
give_segs = _segments(dear)
give = tuple(Run(g[0][0], g[-1][1], _mean(g)) for g in give_segs)
worth = any(s[0] >= cheap.end and s[2] >= cheap.mean_eur_kwh
+ float(required_eur_kwh) for s in dear)
if not give:
return RunPlan(cheap, (), None, level, NOT_WORTH)
# Sell: inside one give run, never over a slot priced below 0.
sellable = [part for g in give_segs
for part in _segments([s for s in g if s[2] >= 0.0])]
sell = None
if sellable:
sw = _windows(sellable, need_hours(sell_kwh, sell_kw, slots))
top = max(sw, key=lambda w: (round(_mean(w), 9),
-w[0][0].timestamp()))
sell = Run(top[0][0], top[-1][1], _mean(top))
return RunPlan(cheap, give, sell, level, None, worth)
def keep_started(old: RunPlan | None, new: RunPlan, now: datetime) -> RunPlan:
"""A run under way keeps going to its end (no flapping on a new plan): the
cheap run, the give run and the sell run each come from `old` while `old`
holds `now` in it. The rest is `new`. A safety or peak rule can still stop
a run at any tick; that is not decided here."""
if old is None or not old.on:
return new
cheap = old.cheap if old.cheap and old.cheap.holds(now) else new.cheap
sell = old.sell if old.sell and old.sell.holds(now) else new.sell
g_old = old.give_at(now)
give = new.give
if g_old is not None:
give = (g_old,) + tuple(g for g in new.give
if g.start >= g_old.end)
if cheap is new.cheap and sell is new.sell and g_old is None:
return new
worth = old.charge_worth if cheap is old.cheap else new.charge_worth
return RunPlan(cheap, give, sell, new.level_eur_kwh if new.on
else old.level_eur_kwh, None if give else new.reason,
worth)
+274
View File
@@ -0,0 +1,274 @@
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
a different box from a different vendor that reports into Home Assistant
through its own integration - so the only place a product can read production
for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever
`sensor.*` the site's own PV integration provides.
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
`pv_entity` as a single string and the owner of the reference site reported
within minutes of 0.11.0 going live that they have SEVERAL PV inverters and
could configure one. That is not a property of one house: a multi-string or
multi-orientation roof routinely carries two or three inverters, each
publishing its own power sensor. `entities()` below is the roster and `total()`
is the site figure over it.
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
Three inverters configured, one `unavailable`, and the sum of the other two is A
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
that reads as "there was less PV than there was" - which is the "no irradiance"
conclusion the card exists to TEST rather than to manufacture. So `total()`
answers `None` the moment ANY configured entity is absent. See the note on
`total()` for the alternative that was weighed and why it lost.
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
charging at all - and could not say WHY, because PV production is recorded
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
a charge-power limit; the optimiser declining to charge - the only software
defect), and guessing between them is how an optimiser gets tuned to solve a
cloud problem. This module is the missing measurement and NOTHING ELSE.
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
production as a term of the control law would be an unrequested behaviour change
AND it would destroy the measurement, because the loop would then be reacting to
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
untouched: it grades a control document that contains none of this material on
both trees and with `pv_entity` set and unset.
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
dead sensor would manufacture exactly the "there was no surplus to store"
conclusion the card exists to TEST - and it would do it silently, in a week of
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
SITE figure, and it publishes as an absence. A partial sum is a second way of
reporting 0-where-there-was-production, one layer up, and it is refused for the
same reason.
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
"""
from .hass import BAD_STATES
# Home Assistant's OWN power units, each as the number of watts one of it is.
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
# exact power of ten, so none of them is a number anybody chose.
#
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
# is a measured conversion rather than a decimal shift, no PV integration
# publishes in it, and a wrong constant in the one channel that exists to
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
# It lands in the "not a power unit" refusal below, BY NAME.
WATTS_PER = {
"mW": 0.001,
"W": 1.0,
"kW": 1000.0,
"MW": 1000000.0,
"GW": 1000000000.0,
"TW": 1000000000000.0,
}
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
# absence whose cause is invisible sends an operator to look at the wrong box.
UNIT_KEY = "unit_of_measurement"
def watts(state: dict | None) -> tuple:
"""`(watts, why)` from a Home Assistant state object.
`watts` is `None` whenever there is no reading - and `why` then says which
of the five refusals it was, in words an operator can act on. On a good read
`why` is the unit the value was read in, because "1234 W" and "1234, unit
unknown" are different claims and the page has to be able to make the first.
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
real production at dusk is an ordinary reading, and that heuristic would
publish it as 30 kW - a fabricated number, in the channel whose entire
purpose is to be believed a week later. A sensor whose unit this cannot read
is REFUSED rather than guessed at.
"""
if state is None:
# `hass.state()` answers None for a non-existent entity, a 404 and a
# transport failure alike. All three are "no reading", and HA does not
# consider a non-existent entity an error - see `hass.state()`.
return None, "no state (entity missing, or the read failed)"
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
# RESPELLED. A second copy of it is how one path starts treating
# `unavailable` as a number the other one rejects.
return None, f"entity is {raw or 'empty'}"
try:
value = float(raw)
except ValueError:
return None, f"state {raw!r} is not numeric"
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
if not unit:
return None, ("no unit_of_measurement on the entity - it cannot be "
"read in watts")
if unit not in WATTS_PER:
return None, (f"unit {unit!r} is not one of "
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
return value * WATTS_PER[unit], unit
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
def entities(opts: dict) -> tuple:
"""Every PV entity this configuration names, in configured order. TEL-12.
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
BOTH sides read. `Controller.pv_roster` is this call and
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
whether the row is ANNOUNCED, the thing that decides whether it is READ and
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
already had that discipline for one entity and spelled the predicate TWICE
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
that nothing feeds; a roster makes it how a SUM gets taken over a different
list from the one that was announced. It is one function now.
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
to the only upgrade path that matters. The install this card exists for HAS
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural
action is to put ALL the inverters in the new repeatable field - and under a
UNION rule that double-counts A unless the operator also remembers to clear
the old key. A doubled total is the same defect as a partial one in the
other direction, in the one channel that exists to be believed a week later.
Under REPLACE the effective roster is exactly what one key says, and no
arithmetic depends on a de-duplication rule the operator has to know about.
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
the replacement is stated on the page rather than being silent.
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
two sensors with one entity_id, so a repeated id in the list is never a
second inverter - it is a typo that would DOUBLE that inverter's production
in the total. `dict.fromkeys` is the order-preserving spelling.
"""
listed = [e for e in (str(x or "").strip()
for x in (opts.get("pv_entities") or [])) if e]
if listed:
return tuple(dict.fromkeys(listed))
one = str(opts.get("pv_entity") or "").strip()
return (one,) if one else ()
def entity_is_ignored(opts: dict) -> bool:
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
exists: the rule and the sentence that reports the rule must not be two
readings of the options. This is the negative half of `entities()`' first
branch and it is spelled once, beside it, where an edit to one is in front
of whoever is editing the other.
"""
return bool(str(opts.get("pv_entity") or "").strip()
and [x for x in (opts.get("pv_entities") or [])
if str(x or "").strip()])
def inverted(opts: dict) -> frozenset:
"""The entity ids `pv_invert_entities` flips. TEL-14."""
return frozenset(e for e in (str(x or "").strip()
for x in (opts.get("pv_invert_entities") or []))
if e)
def stray_inverts(opts: dict) -> list:
"""`pv_invert_entities` entries naming no entity in the roster - a typo
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
roster = set(entities(opts))
return sorted(e for e in inverted(opts) if e not in roster)
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
# clears that with room for a couple of such devices on one entity, while a
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
NEGATIVE_W = -50.0
def total(readings, flip=frozenset()) -> tuple:
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
per configured entity, each `state` shaped as `hass.state()` returns.
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
`watts()`, kept because the operator surfaces have to be able to name which
sensor is missing. `watts` is the sum when every entity answered, and `None`
otherwise; `why` always says which of those it was, in words a status page
can print.
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
the decision the card turns on and it was taken deliberately against the
alternative, which was to publish the partial sum ALONGSIDE a count of how
many entities contributed. Both are defensible; this one wins on the ONE
question that matters, which is who reads the number and when:
* the MQTT row is read back COLD, a week later, by OPT-10's attribution
and by whatever plots `sensor.goodwe_pv_power` over that week. A partial
sum there is a smaller number indistinguishable from a real one, and the
count that would have qualified it lives in a SECOND entity with its own
history - so believing the total requires joining two recorder series by
timestamp to find out which samples to distrust. Nobody re-derives that,
which is the same "nobody will re-derive it" TEL-11's note names. And it
is worse than a gap in exactly the direction that matters: a gap makes
the attribution say "I do not know", a partial sum makes it say "there
was less sun than there was".
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
the partial information is not thrown away - `Controller.checks()` names
every entity that DID report, with its watts, and every one that did
not, with its reason, in a row that states plainly that there is no site
total. Different reader, different instant, different rule.
A count published beside the total would also carry no information in the
state this function actually admits: the total is published ONLY when every
entity contributed, so the count would be the constant `len(parts)`.
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
beside two that are producing must give the SUM of the three, not blank the
site: shading, a tripped string and nightfall are all real 0s, and a roster
that went to `unknown` whenever one member read zero would report an absence
on every clear evening and defeat the channel from the other end.
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
before the sum, so every surface reads the corrected figure. `0.0 - w`
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
"""
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
if not parts:
# No roster. `Controller` does not call this in that state - the gate is
# `pv_roster` - but a reader of `parts` should never have to wonder.
return None, "not configured", parts
if len(parts) == 1:
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
# and the SAME `why` string, passed through rather than re-composed, so
# 0.11.0's single-entity surfaces are byte-identical rather than
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
return parts[0][1], parts[0][2], parts
missing = [(ent, why) for ent, w, why in parts if w is None]
if missing:
return None, (
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
f"reporting and a partial sum would understate production: "
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
return (sum(w for _e, w, _y in parts),
f"{len(parts)} entities", parts)
+31 -11
View File
@@ -82,6 +82,7 @@ import os
import aiohttp
from . import messages
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
@@ -130,11 +131,16 @@ class SaveRefused(Exception):
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
def __init__(self, kind, problems=(), changed=(), stored=None,
moved=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
# against stored-now - when the caller knows what it opened. None
# when it does not. `changed` also holds the operator's own edits.
self.moved = None if moved is None else list(moved)
super().__init__(f"save refused ({kind})")
@@ -287,7 +293,7 @@ async def problems_for(opts: dict, hass=None) -> list:
return problems
async def problems_and_ranges(opts: dict, hass=None):
async def problems_and_ranges(opts: dict, hass=None, notes=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
@@ -302,6 +308,8 @@ async def problems_and_ranges(opts: dict, hass=None):
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
one. That was the first shape of this and it disagreed with itself.
UX-13: a list passed as `notes` collects the validator's warnings too.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
@@ -328,7 +336,7 @@ async def problems_and_ranges(opts: dict, hass=None):
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
found = list(validate_startup_config(opts, notes=notes))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
@@ -346,7 +354,8 @@ async def problems_and_ranges(opts: dict, hass=None):
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems = list(validate_startup_config(opts, dev_min, dev_max,
notes=notes))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
@@ -355,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None):
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
hass=None, base=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
@@ -378,11 +387,17 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
`base`, when given, is the blob the page opened (the one `expect` is the
fingerprint of). A stale refusal then also names the keys that moved
underneath it, so the page does not list the operator's own edits among
the settings "changed elsewhere" (UX-13 P2 test, finding a).
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
_LOG.warning("The settings page did not save, because of %s. The "
"first: %s", messages.count(len(problems), "problem"),
problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
@@ -390,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
moved = (changed_keys(base, stored)
if isinstance(base, dict) and fingerprint(base) == expect
else None)
keys = changed if moved is None else moved
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(keys) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored,
moved=moved)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
+90
View File
@@ -0,0 +1,90 @@
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
`applies()` says when: the startup gate's only refusal is that no inverter is
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
controller. Nothing that can command is built: no Controller, no inverter, no
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
before anything is sent (hass.py), and `/action` refuses too (web.py).
"""
import asyncio
import contextlib
import logging
import signal
from datetime import datetime, timezone
from . import messages, web
from .inverter import roster_entries
from .validate import actuator_writers, no_power_request_problem
_LOG = logging.getLogger("goodwe.setup")
def applies(opts: dict, problems) -> bool:
"""True when the options name no inverter at all and that is the gate's
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
return (not actuator_writers(opts) and not roster_entries(opts)
and list(problems) == [no_power_request_problem()])
class SetupOnly:
"""What web.build_app() needs from a controller, and nothing that writes.
It holds no inverter and no publisher. `hass` must be read-only."""
setup_only = True
auto = False # control is never started here
grid = None # no meter is read here
inverters = ()
def __init__(self, opts: dict, hass, store):
if getattr(hass, "read_only", False) is not True:
raise ValueError("setup-only mode needs a read-only HomeAssistant")
self.o, self.hass, self.store = opts, hass, store
self.events: list[str] = []
self.hint = ""
def log_event(self, msg: str, hint: str | None = None) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def status_payload(self, lang: str = "en") -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
# web.py answers 409 before it gets here. Nothing is done either way.
_LOG.warning("The page asked for an action in setup, so nothing "
"was done.")
return None
async def shutdown(self) -> None:
"""Nothing to wind down: nothing was ever commanded."""
async def serve(opts: dict, hass, store, *, port: int) -> None:
"""Serve the page in setup-only mode until the Supervisor stops us."""
controller = SetupOnly(opts, hass, store)
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
"controls nothing. Open its page to finish setup.")
controller.log_event("setup-only mode: no inverter named, nothing is "
"commanded", hint=messages.hint("setup_only"))
runner = await web.start(controller, port=port)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
try:
await stop.wait()
finally:
await runner.cleanup()
_LOG.info("stopped")
+31 -43
View File
@@ -279,6 +279,8 @@ that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
from . import messages
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
@@ -316,11 +318,8 @@ WIRE_W = 0.0
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
"This is an upper limit, not a forecast, because the meter did not see "
"the battery move.")
def mode(opts: dict) -> str:
@@ -371,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
# UX-08: the same four rungs, in plain words. The setting is named by its
# value, "automatic" for `auto`, so each rung still reads differently.
m = mode(opts)
live = "what the add-on decides reaches the inverters"
inert = "the add-on decides but sends nothing"
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
return f"Off, {live}"
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
return f"On, {inert}"
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
return f"Off (automatic, control is started), {live}"
return f"On (automatic, control is stopped), {inert}"
def words(opts: dict, *, control_started: bool, would_w: float | None,
@@ -409,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
that did not respond; "would ask for -2 000 W" alone reads as a forecast
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
@@ -429,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
return (f"{head}. No decision yet, the inverters stay at "
f"{WIRE_W:.0f} W.")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
f"at {WIRE_W:.0f} W.")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
out += f" Why: {reason}."
return out
@@ -471,25 +470,17 @@ def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
"""`(level, text)` for the top of the status page, or `None` when live.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
⚠️ IT GOES RED FOR A STANDING LATCH. The shadow banner sits ABOVE every
other branch of the cascade, because "the battery is not controlled" is
the one sentence that may never be displaced - but a failsafe latch that
is up while the controller is inert is up when it stops being inert. So the
banner says a safety stop outlasts shadow mode, and each latch keeps its
own red row below, which names it and says how it clears. The words are
`messages.banner_shadow()`'s (UX-09); `latched` names them for callers.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
return ("bad" if latched else "warn", messages.banner_shadow(bool(latched)))
def problems(opts: dict) -> list:
@@ -510,9 +501,6 @@ def problems(opts: dict) -> list:
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
+ ", ".join(f"'{m}'" for m in MODES[:-1])
+ f" or '{MODES[-1]}'."]
+254
View File
@@ -0,0 +1,254 @@
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
this later with the add-on's own forecast (owner answer, 2026-09-27).
Two options name the sensors: `pv_forecast_entity`, the solar energy still
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
is only needed for a cheap run before midnight). Supported, by the entities
they create (DOCS.md lists them):
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
`sensor.energy_production_tomorrow`. The state only; no hourly split.
* Solcast PV Forecast (HACS `solcast_solar`):
`sensor.solcast_pv_forecast_forecast_remaining_today` and
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
`[{period_start, pv_estimate}]`, kW per period.
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
`wh_period`: `{period start: Wh}`.
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
state only. The unit is read, never guessed (the rule `pv.watts()` has).
Without an hourly split, the day's energy is spread over a fixed bell from
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .hass import BAD_STATES
from .tariff import BILLING_TZ
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
# Stale after 3 h. The integrations above refresh at least hourly, so a
# 2 h limit would flag a site whose hourly poll was missed once.
FRESH_S = 3 * 3600.0
# The bell a state-only forecast is spread over, in local hours.
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# Why there is no forecast (the page words are in ui_text `home_words`).
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
@dataclass(frozen=True)
class Day:
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
day0: datetime # local midnight, as UTC
kwh: float
periods: tuple = ()
@dataclass(frozen=True)
class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
def between(self, t0: datetime, t1: datetime) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
if t1 <= t0:
return 0.0
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
total = 0.0
for day in (self.today, self.tomorrow):
if day is None:
continue
a = max(t0, day.day0)
b = min(t1, day.day0 + timedelta(days=1))
if b > a:
total += _day_between(day, a, b, t0)
return total
def _local_hour(t: datetime) -> float:
lt = t.astimezone(BILLING_TZ)
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
def _bell(h: float) -> float:
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
if h <= BELL_FROM_H:
return 0.0
if h >= BELL_TO_H:
return 1.0
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
return (1.0 - math.cos(math.pi * x)) / 2.0
def _hours_since(day0, t):
return (t - day0).total_seconds() / 3600.0
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
if day.periods:
out = 0.0
for s, e, kwh in day.periods:
lo, hi = max(a, s), min(b, e)
if hi > lo:
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
return out
# State only: `kwh` is what is left from `max(now, day0)` to the day's
# end, spread over the bell.
h_from = _hours_since(day.day0, max(now, day.day0))
left = 1.0 - _bell(h_from)
if left <= 1e-9:
return 0.0
share = (_bell(_hours_since(day.day0, b))
- _bell(_hours_since(day.day0, a))) / left
return day.kwh * max(0.0, share)
def _parse_time(v):
try:
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
def _periods(attrs: dict, day0: datetime) -> tuple:
"""The hourly split, if the sensor has one, for the day at `day0`."""
d1 = day0 + timedelta(days=1)
out = []
for key in ("detailedForecast", "detailedHourly"):
rows = attrs.get(key)
if isinstance(rows, list) and rows:
starts = []
for r in rows:
if not isinstance(r, dict):
continue
t = _parse_time(r.get("period_start"))
try:
kw = float(r.get("pv_estimate"))
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(kw) and kw >= 0:
starts.append((t, kw))
starts.sort()
for i, (t, kw) in enumerate(starts):
step = (starts[i + 1][0] - t if i + 1 < len(starts)
else timedelta(minutes=30 if key ==
"detailedForecast" else 60))
if step <= timedelta(0) or step > timedelta(hours=1):
step = timedelta(minutes=30 if key == "detailedForecast"
else 60)
if day0 <= t < d1:
out.append((t, t + step,
kw * step.total_seconds() / 3600.0))
if out:
return tuple(out)
wh = attrs.get("wh_period")
if isinstance(wh, dict) and wh:
rows = []
for k, v in wh.items():
t = _parse_time(k)
try:
e = float(v)
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(e) and e >= 0:
rows.append((t, e / 1000.0))
rows.sort()
for i, (t, kwh) in enumerate(rows):
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
hours=1)
if nxt - t > timedelta(hours=1) or nxt <= t:
nxt = t + timedelta(hours=1)
if day0 <= t < d1:
out.append((t, nxt, kwh))
return tuple(out)
def _day(state, day0, now, *, today):
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
if state is None:
return None, MISSING
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return None, MISSING
try:
v = float(raw)
except ValueError:
return None, BAD
attrs = state.get("attributes") or {}
unit = str(attrs.get("unit_of_measurement") or "").strip()
if unit not in KWH_PER:
return None, UNIT
kwh = v * KWH_PER[unit]
if not math.isfinite(kwh) or kwh < 0:
return None, BAD
periods = _periods(attrs, day0)
if periods and today:
# The split is the whole day; keep what is still to come, scaled so
# it adds up to the state (the state is what the integration says
# is left).
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
/ (e - s).total_seconds()))
for s, e, k in periods)
if left > 1e-6:
f = kwh / left
periods = tuple((s, e, k * f) for s, e, k in periods)
return Day(day0, kwh, periods), None
def _reported(state):
ts = [_parse_time(state.get(k)) for k in
("last_reported", "last_updated", "last_changed")]
ts = [t for t in ts if t is not None]
return max(ts) if ts else None
def read(today_state, tomorrow_state, now: datetime, *,
configured=True):
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
(the option is not set, or the sensor is missing): the forecast then
covers today only."""
if not configured:
return None, NOT_SET
here = now.astimezone(BILLING_TZ)
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nd = d0.date() + timedelta(days=1)
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
today, why = _day(today_state, day0, now, today=True)
if today is None:
return None, why
at = _reported(today_state)
# A reading from before local midnight is yesterday's "left today" (0
# after sunset) until the integration refreshes: stale, not a dark day.
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
or (at - now).total_seconds() > 300:
return None, STALE
tomorrow = None
if tomorrow_state is not None:
t, _w = _day(tomorrow_state, day1, now, today=False)
t_at = _reported(tomorrow_state)
if t is not None and t_at is not None and t_at >= day0 \
and (now - t_at).total_seconds() <= FRESH_S:
tomorrow = t
return Forecast(today, tomorrow, at), None
+246
View File
@@ -0,0 +1,246 @@
/* UX-13 page styles. Colours follow the approved mockup (UX-13-mockup.html,
version 2) and prefers-color-scheme. No web fonts, no external files. */
:root{
--bg:#f3f4f6;--card:#ffffff;--ink:#1b1d21;--muted:#5b626c;--faint:#8a919b;--line:#e2e5e9;
--accent:#1c64d9;--accent-soft:#e6efff;--on-accent:#ffffff;
--ok:#17773a;--ok-bg:#e6f4eb;--warn:#955300;--warn-bg:#fcefd9;--bad:#c02626;--bad-bg:#fce8e8;--info:#5b626c;--info-bg:#eceef1;
--solar:#c98a00;--house:#6c47c2;--grid:#56657a;--batt:#17773a;
--cheap:#8fcca4;--dear:#f0ad72;--mid:#cfd5dc;--track:#dde1e6;--shadow:0 1px 2px rgba(0,0,0,.05);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}}
:root[data-theme="dark"]{
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
button,input,select{font:inherit;color:inherit}
a{color:var(--accent)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.top{display:flex;align-items:center;gap:12px;padding:12px 16px 8px;max-width:820px;margin:0 auto}
.logo{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);display:grid;place-items:center;color:var(--accent);flex:none}
.app{font-weight:700;font-size:17px;line-height:1.2}
.pill{display:flex;flex-wrap:wrap;align-items:center;gap:0 6px;font-size:13px;color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none}
.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
.tabs{display:flex;gap:4px;max-width:820px;margin:0 auto;padding:0 16px;border-bottom:1px solid var(--line)}
.tab{border:0;background:none;padding:10px 12px;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;min-height:44px;position:relative}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.tab svg{width:20px;height:20px}
.badge{font-size:11px;font-weight:700;background:var(--warn);color:var(--card);border-radius:9px;padding:0 6px;line-height:18px}
.badge.bad{background:var(--bad)}
@media (max-width:640px){
.tabs{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--card);border-top:1px solid var(--line);border-bottom:0;padding:0 4px env(safe-area-inset-bottom);justify-content:space-around;gap:0}
.tab{flex-direction:column;gap:0;font-size:11px;padding:6px 4px;border-bottom:0;border-top:2px solid transparent;flex:1;min-width:0}
.tab[aria-selected="true"]{border-top-color:var(--accent)}
.tab .badge{position:absolute;top:2px;left:calc(50% + 6px)}
.wrap{padding-bottom:96px!important}
}
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 40px}
h2{font-size:15px;margin:0 0 10px;font-weight:650}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:var(--shadow)}
.muted{color:var(--muted)} .small{font-size:13px} .tiny{font-size:12px}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.spread{justify-content:space-between}
.grow{flex:1 1 auto;min-width:0}
.banner{border-radius:12px;padding:12px 14px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start;font-weight:600}
.banner .ic{flex:none;margin-top:2px}
.b-ok{background:var(--ok-bg);color:var(--ok)} .b-warn{background:var(--warn-bg);color:var(--warn)} .b-bad{background:var(--bad-bg);color:var(--bad)}
.banner small{display:block;font-weight:400;color:var(--ink);margin-top:2px}
.tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
@media (max-width:640px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
.tile{border:1px solid var(--line);border-radius:10px;padding:10px;min-width:0}
.tile .lab{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px}
.tile .val{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.25;margin-top:2px}
.tile .val small{font-size:13px;font-weight:500;color:var(--muted)}
.tile .sub{font-size:12px;color:var(--muted)}
.sw-solar{color:var(--solar)} .sw-house{color:var(--house)} .sw-grid{color:var(--grid)} .sw-batt{color:var(--batt)}
.brow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:10px 0;border-top:1px solid var(--line)}
.brow:first-child{border-top:0}
.lvl{height:10px;background:var(--track);border-radius:6px;overflow:hidden;grid-column:1/-1;margin:4px 0 2px}
.lvl>i{display:block;height:100%;background:var(--batt);border-radius:6px}
.lvl.none>i{background:var(--faint)}
/* the plan chart */
.chart{position:relative;margin:18px 0 4px}
.bars{display:flex;align-items:flex-end;gap:2px;height:72px}
.bars i{flex:1;border-radius:3px 3px 0 0;background:var(--mid);min-width:0}
.bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
.bars i.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
.lane b{position:absolute;top:2px;bottom:2px;border-radius:3px;min-width:3px}
.lane .house{background:repeating-linear-gradient(90deg,var(--mid) 0 6px,transparent 6px 9px);top:6px;bottom:6px}
.lane .grid{background:var(--accent)}
.lane .give{background:repeating-linear-gradient(135deg,var(--house) 0 4px,color-mix(in srgb,var(--house) 55%,var(--card)) 4px 7px)}
.lane .cycle{background:repeating-linear-gradient(90deg,var(--warn) 0 3px,var(--warn-bg) 3px 6px)}
.lane .peak{background:var(--bad)}
.lanelab{font-size:12px;color:var(--muted);margin-top:6px}
.nowline{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
.nowline span{position:absolute;top:-15px;left:-10px;font-size:10px}
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:4px}
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted);margin-top:8px}
.legend span{display:inline-flex;align-items:center;gap:5px}
.legend i{width:14px;height:10px;border-radius:2px;display:inline-block}
.legend i.house{background:repeating-linear-gradient(90deg,var(--mid) 0 4px,transparent 4px 6px)}
ul.plain{list-style:none;margin:8px 0 0;padding:0}
ul.plain li{padding:6px 0;border-top:1px solid var(--line)}
ul.plain li:first-child{border-top:0}
.meter{height:12px;background:var(--track);border-radius:6px;overflow:hidden;margin:8px 0 4px}
.meter i{display:block;height:100%;background:var(--accent)}
.meter i.warn{background:var(--warn)} .meter i.bad{background:var(--bad)}
.alert{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)}
.alert:first-child{border-top:0}
.sev{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:13px;font-weight:800}
.sev.warning{background:var(--warn-bg);color:var(--warn)} .sev.alarm{background:var(--bad-bg);color:var(--bad)} .sev.info{background:var(--info-bg);color:var(--info)} .sev.ok{background:var(--ok-bg);color:var(--ok)}
.alert .t{font-weight:600}
.btn{border:1px solid var(--line);background:var(--card);padding:9px 14px;border-radius:10px;cursor:pointer;min-height:44px}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
.btn.danger{color:var(--bad);border-color:var(--bad)}
.btn:disabled{opacity:.5;cursor:not-allowed}
a.btn{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink)}
details.more{margin-top:12px;border-top:1px solid var(--line)}
details.more>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
table.rows{width:100%;border-collapse:collapse;font-size:13px}
table.rows td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
table.rows td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;padding-left:12px;overflow-wrap:anywhere}
table.rows tr:last-child td{border-bottom:0}
ul.checks{margin:6px 0 0;padding-left:0;list-style:none;font-size:13px}
ul.checks li{margin:3px 0}
.c-ok{color:var(--ok)} .c-warn{color:var(--warn)} .c-bad{color:var(--bad)}
.kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;font-size:14px;margin-top:10px}
.kv dt{color:var(--muted)} .kv dd{margin:0}
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;z-index:30;background:var(--ink);color:var(--bg);padding:10px 14px;border-radius:10px;font-size:14px;max-width:calc(100% - 32px);opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
@media (max-width:640px){#toast{bottom:84px}}
dialog{border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);max-width:min(420px,calc(100% - 32px));padding:18px}
dialog::backdrop{background:rgba(0,0,0,.45)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
.tip{position:fixed;z-index:40;background:var(--ink);color:var(--bg);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:8px;pointer-events:none;max-width:240px;opacity:0;transition:opacity .1s;box-shadow:0 2px 8px rgba(0,0,0,.25);left:0;top:0}
.tip.show{opacity:1}
.tip b{display:block;font-weight:700}
.tip span{display:block}
.chart [data-tip]{cursor:default}
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* UX-13 P2: Settings, the Installer view (mockup version 2) */
.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0}
.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px}
.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap}
.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)}
.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)}
details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)}
details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary .grow{flex:1 1 200px}
details.grp>summary .gdesc{display:block}
details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)}
details.grp[open]>summary .chev{transform:rotate(90deg)}
.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px}
details.grp .body{padding:0 14px 8px}
details.sub{border-top:1px solid var(--line);margin-top:4px}
details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700}
.f{padding:12px 0;border-top:1px solid var(--line);min-width:0}
.part>.f:first-child,.subhead+.f{border-top:0}
.f .lbl{font-weight:600;font-size:14px}
.f .help{font-size:13px;color:var(--muted);margin-top:4px}
.f .ctl{margin-top:8px;min-width:0}
.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start}
.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2}
.f.inline .help,.f.inline .msgs{grid-column:1/-1}
.f.changed>.head .lbl::after{content:" •";color:var(--accent)}
.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere}
.msg .mi{font-weight:800;flex:none}
.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)}
.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)}
.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0}
.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s}
.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s}
.switch[aria-checked="true"]::before{background:var(--accent)}
.switch[aria-checked="true"]::after{left:25px}
.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px}
.swtext{font-size:13px;color:var(--muted)}
select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%}
select.sel{width:100%}
.row>select.sel{width:auto;flex:1 1 200px}
input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums}
input.txt{width:100%}
input:disabled{opacity:.6}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap}
.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)}
.seg button:first-child{border-left:0}
.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px}
.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)}
.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px}
.choice[aria-checked="true"] .rad{border:5px solid var(--accent)}
.slider{display:flex;align-items:center;gap:10px;width:100%}
.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px}
.unit{color:var(--muted);font-size:13px;white-space:nowrap}
.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center}
.ent-btn .grow{min-width:0}
.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden}
.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px}
.ent-note{padding:6px 10px}
.ent-list{max-height:240px;overflow:auto}
.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px}
.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)}
.ent-opt .grow{min-width:0}
.ent-opt .grow>div{overflow-wrap:anywhere}
.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap}
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
.elist>.btn{margin-top:8px}
.plist .prow{display:grid;gap:6px}
.plist .pf{display:flex;flex-direction:column;gap:2px}
.plist .pf>select.sel{width:100%}
input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
.inv>.f:nth-child(2){border-top:0}
.invs>.btn{margin:4px 0 8px}
#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15}
#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)}
#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)}
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
@media (max-width:640px){#savebar{bottom:62px}}
#s-set{padding-bottom:80px}
/* UX-13 P4: the setup wizard */
.steps{display:flex;gap:4px;margin:4px 0 12px}
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
.msg.ok{background:var(--ok-bg);color:var(--ok)}
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
.kettle .row{margin-top:8px}
/* UX-13 P3: the Batteries screen and the Simple settings view */
.bcard .hd{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.bcard .name{font-weight:700;font-size:16px}
.bcard .modebox .lbl{display:block;margin-bottom:4px}
.bcard .modebox .help{font-size:13px;color:var(--muted);margin-top:4px}
.bcard.off .modebox{opacity:.6}
.bcard .kv dd{overflow-wrap:anywhere}
.switch:disabled{opacity:.45;cursor:not-allowed}
select.sel:disabled{opacity:.6}
.simple .card>.row{margin-bottom:4px}
.vhide{display:none!important}
.simple .card>.f{border-top:0;padding-top:0}
.simple .f>.head{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.simple .card .lbl{font-weight:600;font-size:15px}
.simple .inv .hd{border-bottom:0}
.simple .inv .f{border-top:0;padding-top:0}
+490
View File
@@ -0,0 +1,490 @@
"use strict";
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
external files. Every URL is relative, because ingress serves the add-on
under a generated path prefix. Every word comes from app/ui_text (fixed
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
is checked in Python. Data is always set as text, never as HTML. */
(function () {
// P5: English or Dutch, by the browser's language. A word missing in Dutch
// falls back to English, key by key (app/uitext.py does the same).
const ALL = JSON.parse(document.getElementById("ui-text").textContent);
const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en";
const merge = (a, b) => {
if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a;
const out = Object.assign({}, a);
for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k];
return out;
};
const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]);
document.documentElement.lang = LANG;
const T = WORDS.home;
const $ = (s) => document.querySelector(s);
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
function h(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const k in attrs || {}) {
const v = attrs[k];
if (v === false || v == null) continue;
if (k === "class") e.className = v;
else if (k === "style") e.style.cssText = v;
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v === true ? "" : v);
}
for (const c of kids.flat()) {
if (c == null || c === false) continue;
e.append(c.nodeType ? c : document.createTextNode(String(c)));
}
return e;
}
const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x);
const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1));
const eur = (v) => "€" + dec(Number(v).toFixed(2));
// Fixed labels from ui_text.
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
let S = null; // the last /status
let busy = false;
/* ---------------- toast and confirm dialog ---------------- */
function toast(text) {
const e = $("#toast"); e.textContent = text; e.classList.add("show");
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
}
let onYes = null;
// `key` names Home's words in ui_text, or is {name, description, yes}.
function ask(key, fn) {
const w = typeof key === "string" ? t("dialog." + key) : key;
$("#dlg-t").textContent = w.name;
$("#dlg-b").textContent = w.description;
$("#dlg-yes").textContent = w.yes;
onYes = fn;
const d = $("#dlg");
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
}
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
/* ---------------- data ---------------- */
async function getStatus() {
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
if (!r.ok) throw new Error("status " + r.status);
return r.json();
}
async function act(what) {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what }) });
if (!r.ok) throw new Error("action " + r.status);
return r.json();
}
// `force`: redraw the battery cards even under a focused mode list, after
// the person used it (P3).
async function refresh(force) {
if (busy) return;
busy = true;
try { S = await getStatus(); render(force === true); }
catch (e) { offline(); }
finally { busy = false; }
}
/* ---------------- shell ---------------- */
function banner(el, level, title, sub, role) {
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, sub ? h("small", {}, sub) : null)));
}
function offline() {
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render(force) {
renderPill();
const structured = S.now && S.plan && !S.home_error;
// UX-15: setup-only mode commands nothing and has no control to offer.
const setup = S.setup_only === true;
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
renderControl(structured, setup);
renderDetails(!structured && !setup);
renderBatteries(force);
document.dispatchEvent(new CustomEvent("ux13:status"));
}
/* ---------------- Home: now ---------------- */
function val(id, w, show) {
const e = $(id);
if (w == null || !show) { e.textContent = "–"; return; }
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
}
function levelBar(pct, label) {
const known = pct != null;
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
}
function renderNow() {
const n = S.now;
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
levelBar(b.level_pct, b.name),
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
}
/* ---------------- Home: today's plan ---------------- */
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
["peak", "background:var(--bad)"], ["house", null]];
function tipText(lines) { return lines.filter(Boolean).join(" "); }
function chartItem(tag, cls, style, title, lines) {
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
}
function roving(group) {
const items = [...group.querySelectorAll("[data-tip]")];
items.forEach((el, i) => {
el.tabIndex = i === 0 ? 0 : -1;
el.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
if (d === undefined) return;
e.preventDefault();
const j = Math.max(0, Math.min(items.length - 1, i + d));
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
});
});
}
function renderPlan() {
const p = S.plan;
$("#verdict").textContent = p.verdict;
$("#plan-head").textContent = p.head;
const hours = p.hours || 24;
const bars = $("#bars");
const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name"));
bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
}));
roving(bars);
$("#no-prices").hidden = known.length > 0;
const lanes = $("#lanes");
lanes.replaceChildren();
S.batteries.forEach((b, i) => {
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
for (const r of runs) {
if (r.end_h <= r.start_h) continue;
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
}
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
roving(lane);
});
const nl = $("#nowline");
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
const used = new Set(p.runs.map((r) => r.kind));
known.forEach((b) => used.add(b.kind));
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
const list = [];
S.batteries.forEach((b, i) => {
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
}
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
});
for (const line of p.lines) list.push(h("li", {}, line));
$("#plan-list").replaceChildren(...list);
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
}
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
(function () {
const tip = $("#tip"); let hot = null;
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
function show(el, x, y) {
if (hot && hot !== el) hot.classList.remove("hot");
hot = el; el.classList.add("hot");
const lines = JSON.parse(el.dataset.tip);
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
tip.classList.add("show");
const r = tip.getBoundingClientRect();
let L = x + 12, T2 = y - r.height - 12;
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
if (L < 8) L = 8;
if (T2 < 8) T2 = y + 16;
tip.style.left = L + "px"; tip.style.top = T2 + "px";
}
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
document.addEventListener("pointermove", (e) => {
if (e.pointerType === "touch") return;
const el = target(e);
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
});
document.addEventListener("pointerdown", (e) => {
const el = target(e);
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
else if (!el) hide();
});
document.addEventListener("focusin", (e) => {
const el = target(e);
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
});
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
addEventListener("scroll", hide, { passive: true });
})();
/* ---------------- Home: peak, attention, control, details ---------------- */
function renderPeak() {
const m = S.month_peak;
$("#peak-month").textContent = m.month;
const covered = m.state !== "not_covered";
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
const bar = $("#peak-meter i");
bar.style.width = pct + "%";
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
$("#peak-line").textContent = m.line;
}
function renderAttention() {
const list = S.attention;
const icon = { alarm: "×", warning: "!", info: "i" };
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
h("div", { class: "small" }, a.body),
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
const n = list.filter((a) => a.severity !== "info").length;
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
const badge = $("#attn-badge");
badge.textContent = n; badge.hidden = !n;
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
}
function renderControl(structured, setup) {
const running = structured ? S.control.running : null;
const btn = $("#btn-ctl");
btn.textContent = running === false ? t("control.start") : t("control.stop");
btn.className = "btn" + (running === false ? " primary" : "");
btn.hidden = !!setup;
$("#btn-setup").hidden = !setup;
$("#c-ctl .more").hidden = !!setup;
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
$("#hint").textContent = S.hint || "";
const docs = $("#docs-link");
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
}
function renderDetails(open) {
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
return h("li", { class: cls }, mark + " " + c.text);
}));
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries (UX-13 P3) ---------------- */
// One card per battery. With battery modes on (`mode_key` in /status) each
// card has an On/Off switch and a mode list, sent as `pack_use` and
// `pack_mode` through /action (OPT-11 P1). Selling has no switch while it
// is off (owner decision 2026-09-29). The Now, Today and Why lines are
// homeview.py's sentences. The Simple settings view draws the same switch
// and list into its own card (simpleHost).
const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
let packBusy = false;
let simpleHost = null;
async function packAct(what, b, value) {
if (packBusy) return;
packBusy = true;
try {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what, unit: b.name, value: value }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) toast(j.error || t("toast.failed"));
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
await refresh(true);
}
function onSwitch(b, i, where, lid) {
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
"aria-checked": String(b.use), "aria-labelledby": lid,
onclick: () => packAct("pack_use", b, !b.use) });
return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off")));
}
function modeSelect(b, i, where, lid) {
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
onchange: (e) => packAct("pack_mode", b, e.target.value) },
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
}
function todayLines(b) {
if (!b.today || !b.today.length) return b.today_line || "–";
return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what));
}
function batteryCard(b, i) {
const modes = b.mode_key != null;
const lid = "bname-" + i;
const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %";
const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : "";
return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") },
h("div", { class: "hd" },
h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name),
h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")),
modes ? onSwitch(b, i, "c", lid) : null),
modes ? h("div", { class: "modebox" },
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
modeSelect(b, i, "c", null),
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
}
function simpleRow(b, i) {
const lid = "bsname-" + i;
return h("div", { class: "inv" },
h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)),
h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")),
h("div", { class: "ctl" }, modeSelect(b, i, "s", null)),
h("div", { class: "help" }, b.now + " " + (b.why || ""))));
}
// Rebuild without losing the focused control, and never under an open list
// or a request in flight.
function keepFocus(fn) {
const id = document.activeElement && document.activeElement.id;
fn();
if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); }
}
// The modes-off card's button opens Settings on the Installer view, where
// the "Battery modes" switch is (settings.js sets window.UX13.settingsView).
$("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); });
function renderBatteries(force) {
if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; }
const a = document.activeElement;
if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return;
const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null);
$("#batt-off").hidden = modes;
$("#batt-how").hidden = !modes;
const docs = $("#batt-docs");
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
else $("#batt-banner").replaceChildren();
keepFocus(() => {
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
// OPT-11 P4: the solar forecast line under the cards.
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
if (simpleHost && simpleHost.isConnected) {
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
}
});
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
// nothing when control is already where the person asked it to be.
// One request at a time: a second confirm while the first is in flight
// would read the same state and toggle it back.
let toggling = false;
async function setControl(want) {
if (toggling) return;
toggling = true;
try {
const now = await getStatus();
if (!now.control || now.control.running !== want) await act("auto_toggle");
toast(want ? t("toast.started") : t("toast.stopped"));
} catch (e) { toast(t("toast.failed")); }
finally { toggling = false; }
refresh();
}
$("#btn-ctl").addEventListener("click", () => {
const running = S && S.control ? S.control.running : true;
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
});
async function simple(what) {
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
refresh();
}
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
// Shared with settings.js, which loads after this file.
// `first`: the page opened with no route, so settings.js may open the setup
// wizard on a new install (UX-13 P4).
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
// P3: the Simple settings view's battery card, drawn from /status.
batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); },
status: () => S, kw };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
function select(tab, focus) {
TABS.forEach((x) => {
const on = x === tab;
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
});
if (focus) tab.focus();
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
schedule();
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
}
TABS.forEach((x, i) => {
x.addEventListener("click", () => select(x, false));
x.addEventListener("keydown", (e) => {
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
if (j === undefined) return;
e.preventDefault(); select(TABS[j % TABS.length], true);
});
});
function fromHash() {
const want = (location.hash || "#home").slice(1);
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
}
addEventListener("hashchange", fromHash);
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
let timer = null;
function schedule() {
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
$("#t-batt").getAttribute("aria-selected") === "true");
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
if (!live && timer) { clearInterval(timer); timer = null; }
}
document.addEventListener("visibilitychange", schedule);
fromHash();
refresh();
})();
+176
View File
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Battery add-on</title>
<style>/*APP_CSS*/</style>
</head>
<body>
<header class="top">
<div class="logo" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2M8 10l-2 3h4l-2 3"/></svg></div>
<div class="grow">
<div class="app" data-t="app.name"></div>
<div class="pill" id="pill" aria-live="polite"><span class="dot warn"></span><span>…</span></div>
</div>
</header>
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
</nav>
<main class="wrap">
<section class="screen" id="s-home" role="tabpanel" aria-labelledby="t-home">
<div id="home-banner"></div>
<div class="card" id="c-now">
<div class="row spread"><h2 data-t="now.name"></h2><span class="tiny muted" data-t="now.description"></span></div>
<div class="tiles">
<div class="tile"><div class="lab"><span class="sw-solar" aria-hidden="true">&#9728;</span><span data-t="now.solar"></span></div><div class="val" id="v-solar">–</div><div class="sub" id="sub-solar"></div></div>
<div class="tile"><div class="lab"><span class="sw-house" aria-hidden="true">&#8962;</span><span data-t="now.house"></span></div><div class="val" id="v-house">–</div><div class="sub" id="sub-house"></div></div>
<div class="tile"><div class="lab"><span class="sw-grid" aria-hidden="true">&#8645;</span><span data-t="now.grid"></span></div><div class="val" id="v-grid">–</div><div class="sub" id="sub-grid"></div></div>
<div class="tile"><div class="lab"><span class="sw-batt" aria-hidden="true">&#9646;</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
</div>
<div id="home-batts" style="margin-top:6px"></div>
</div>
<div class="card" id="c-plan">
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
<div class="small" id="plan-head"></div>
<div class="chart" id="chart">
<div class="bars" id="bars" role="group"></div>
<div id="lanes"></div>
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
<div class="legend" id="legend"></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
<h3 class="sr" data-t="plan.list"></h3>
<ul class="plain small" id="plan-list"></ul>
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
</div>
<div class="card" id="c-peak">
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
<div class="tiny muted" id="peak-line"></div>
</div>
<div class="card" id="c-attn">
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
<div id="alerts"></div>
</div>
<div class="card" id="c-ctl">
<h2 data-t="control.name"></h2>
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
<details class="more">
<summary data-t="control.more"></summary>
<div class="row" style="padding-bottom:6px">
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
</div>
</details>
</div>
<details class="card more" id="c-details">
<summary data-t="details.name"></summary>
<table class="rows" id="rows"></table>
<h2 style="margin-top:12px" data-t="details.checks"></h2>
<ul class="checks" id="checks"></ul>
</details>
</section>
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="batt-banner"></div>
<div class="card" id="batt-off" hidden>
<h2 data-t="batteries.modes_off.name"></h2>
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
<div id="bcards"></div>
<p class="small muted" id="batt-solar" hidden></p>
<details class="grp" id="batt-how" hidden>
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">&#8250;</span></summary>
<div class="body small">
<p data-t="batteries.how.solar_first.description"></p>
<p data-t="batteries.how.leftover_solar.description"></p>
<p data-t="batteries.how.off.description"></p>
<p class="muted" data-t="batteries.how.rules.description"></p>
<p><a id="batt-docs" hidden target="_top" rel="noopener" data-t="control.docs"></a></p>
</div>
</details>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<div class="row" style="margin-bottom:8px">
<div class="seg" role="radiogroup" id="set-view" data-s-label="view.name">
<button type="button" role="radio" aria-checked="true" data-v="simple" data-s="view.simple"></button>
<button type="button" role="radio" aria-checked="false" data-v="installer" tabindex="-1" data-s="view.installer"></button>
</div>
<span class="tiny muted grow" id="set-desc"></span>
</div>
<div id="set-banner" aria-live="polite"></div>
<div id="simple" class="simple" hidden></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
<div id="groups"><p class="small muted" data-s="loading"></p></div>
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
<p class="tiny muted" data-s="footer.description"></p>
</div>
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card" id="wz">
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
<div class="steps" id="wz-steps" aria-hidden="true"></div>
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
<div id="wz-banner" aria-live="polite"></div>
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
<div class="row spread" id="wz-nav" style="margin-top:14px">
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
</div>
</div>
<p class="tiny muted" data-s="setup.description"></p>
</section>
</main>
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
<h2 id="dlg-t"></h2>
<p class="small" id="dlg-b"></p>
<div class="row" style="justify-content:flex-end">
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="savebar" role="region" hidden><div class="in">
<span class="grow" id="sb-text" aria-live="polite"></span>
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
<script>/*SETTINGS_JS*/</script>
</body>
</html>
+941
View File
@@ -0,0 +1,941 @@
"use strict";
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
from app/uimodel.py), so this file names no option key: keys, labels,
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML.
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
(function () {
const ROOT = window.UX13.words; // English or Dutch (app.js)
const W = ROOT.settings;
const { h, ask, toast, maint, lang } = window.UX13;
const $ = (s) => document.querySelector(s);
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
$("#savebar").setAttribute("aria-label", W.savebar.label);
let M = null; // the last /config/model
let V = null; // the page model being edited
let ORIG = null; // the page model as stored
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
let MSG = []; // [{field, card, tier, text}] from the add-on
let DEVICE = "";
let LIST = null; // the inverter list field
let FILLED = {}; // field key -> the preset field that fills it
let view = null; // "settings" or "setup": the tab drawing V
let STEPS = {}; // wizard step -> refusals that block it
// P3: "simple" (the default, for home owners) or "installer". Remembered in
// this browser only; a blocked storage just means Simple each time.
let mode = "simple";
try { if (localStorage.getItem("ux13.settings.view") === "installer") mode = "installer"; } catch (e) { /* no storage */ }
let uid = 0;
const ROWS = [];
const OPEN = new Set();
const UNLOCKED = new Set();
/* ---------------- the page model ---------------- */
const sub = (f) => f.key.slice(LIST.key.length + 1);
function valueIn(model, f, card) {
if (card == null) return model[f.key];
const c = (model[LIST.key] || [])[card];
return c ? c[sub(f)] : undefined;
}
const get = (f, card) => valueIn(V, f, card);
function visible(f) {
return (f.show || []).every(([name, op, want]) => {
const have = V[name];
if (op === "==") return same(have, want);
if (op === "!=") return !same(have, want);
if (op === "in") return want.some((x) => same(x, have));
if (have == null || have === "" || isNaN(+have)) return false;
return op === ">" ? +have > want : +have < want;
});
}
function put(f, card, v, redraw) {
if (card == null) V[f.key] = v;
else V[LIST.key][card][sub(f)] = v;
if (f.mirror) {
const m = findField(f.mirror);
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
}
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
if (redraw || f.fills) redrawView(); else paint();
queueValidate();
}
function findField(key) {
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.key === key) return f;
for (const s of f.fields || []) if (s.key === key) return s;
}
return null;
}
/* ---------------- numbers: stored units <-> shown units ---------------- */
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
function shown(f, v) {
if (v == null || v === "") return "";
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
}
function stored(f, x) {
const st = f.step || (f.type === "integer" ? 1 : 0);
let v = x * (f.scale || 1);
if (st) v = Math.round(v / st) * st;
if (f.min != null) v = Math.max(f.min, v);
if (f.max != null) v = Math.min(f.max, v);
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
}
/* ---------------- controls ---------------- */
// A choice the page can only keep stays offered while it is the stored value.
function choicesOf(f, card) {
const cur = get(f, card), was = valueIn(ORIG, f, card);
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
return out;
}
function toggle(f, card, lid) {
const on = !!get(f, card);
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
b.addEventListener("click", () => {
const n = b.getAttribute("aria-checked") !== "true";
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
put(f, card, n);
});
return h("span", { class: "swrap" }, b, txt);
}
function radios(f, card, lid, cards) {
const cur = get(f, card);
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
const items = choicesOf(f, card);
const buttons = items.map(([v, label], i) => {
const on = same(v, cur);
const hint = cards && f.hints ? f.hints[v] : "";
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
b.addEventListener("click", () => put(f, card, v, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + items.length) % items.length;
put(f, card, items[j][0], true);
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
if (again) again.focus();
});
return b;
});
g.dataset.rk = f.key + "|" + card;
g.append(...buttons);
return g;
}
function select(f, card, lid) {
const cur = get(f, card);
const s = h("select", { class: "sel", "aria-labelledby": lid });
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
const preset = f.presets && f.presets[cur];
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
}
function slider(f, card, lid) {
if (f.min == null || f.max == null) return number(f, card, lid);
const sc = f.scale || 1;
const st = f.step || (f.type === "integer" ? 1 : 0.01);
const v = get(f, card);
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
n.addEventListener("change", () => {
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
});
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
}
function number(f, card, lid) {
const sc = f.scale || 1;
const by = FILLED[f.key];
const preset = by && by.presets[V[by.key]];
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
n.addEventListener("input", () => {
if (n.value === "") { if (f.optional) put(f, card, null); return; }
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
});
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
}
function text(f, card, lid) {
const v = get(f, card);
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
t.addEventListener("input", () => put(f, card, t.value));
return t;
}
function locked(f, card, lid) {
const box = h("div", {});
const id = f.key + "#" + card;
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
if (UNLOCKED.has(id)) { open(); return box; }
const v = get(f, card);
box.append(h("div", { class: "row" },
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
h("span", { class: "chip" }, W.locked),
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
return box;
}
/* entity picker: search, the matching kind first, the live reading */
const ENT = {};
function entities(kind) {
const now = Date.now();
const e = ENT[kind];
if (e && now - e.at < 30000) return e.p;
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
.catch(() => ({ available: false, entities: [] }));
ENT[kind] = { at: now, p };
return p;
}
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
function picker(f, lid, value, pick) {
let data = null, all = false;
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
const pop = h("div", { class: "ent-pop", hidden: true });
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
const note = h("div", { class: "tiny muted ent-note" });
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
const allBox = h("label", { class: "tiny muted ent-all" },
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
function drawBtn() {
const id = value();
const e = data && data.entities.find((x) => x.entity_id === id);
btn.replaceChildren();
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
}
function opt(id, name, val) {
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
}
function fill() {
const typed = q.value.trim();
const t = typed.toLowerCase();
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
note.textContent = data && data.text ? data.text : "";
note.hidden = !note.textContent;
}
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => {
if (!pop.hidden) { close(); return; }
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
setTimeout(() => q.focus(), 0);
});
q.addEventListener("input", fill);
pop.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const opts = [...list.querySelectorAll(".ent-opt")];
const i = opts.indexOf(document.activeElement);
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
});
pop.append(q, note, list, allBox);
drawBtn();
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
return h("div", { class: "ent" }, btn, pop,
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
}
function entity(f, card, lid) {
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
}
function entityList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((_id, i) => {
box.append(h("div", { class: "erow" },
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
return box;
}
function solarList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((row, i) => {
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
box.append(h("div", { class: "erow" },
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
return box;
}
// P5: time windows, each a start, an end, its days and a price. The add-on
// builds the tariff from the rows and names the first overlap or gap.
function periodList(f, card) {
const P = W.periods;
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist plist" });
if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description);
arr().forEach((row, i) => {
const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); };
const say = (what) => P.window + " " + (i + 1) + ", " + what;
const time = (k) => {
const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00",
value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) });
e.addEventListener("input", () => set(k, e.value.trim()));
return e;
};
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
box.append(h("div", { class: "erow prow" },
h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)),
h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")),
cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))),
cell(P.days, days)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => {
const a = arr(), last = a[a.length - 1];
put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true);
} }, "+ " + P.add));
return box;
}
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList,
period_list: periodList };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
if (view === "setup") return ""; // the wizard shows no keys
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
}
function fieldRow(f, card, gid) {
if (f.control === "inverter_list") return cardList(f, gid);
const lid = "sf" + (++uid);
const keys = gid === "simple" ? "" : keyText(f, card); // Simple shows no keys
const msg = h("div", { class: "msgs" });
const make = CONTROLS[f.control] || text;
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""),
keys ? h("div", { class: "key" }, keys) : null),
h("div", { class: "ctl" }, make(f, card, lid)),
f.help ? h("div", { class: "help" }, f.help) : null, msg);
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
return row;
}
function blankCard(f, first) {
const out = {};
for (const s of f.fields) {
const k = sub(s);
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
}
return out;
}
function cardList(f, gid) {
const wrap = h("div", { class: "invs", "data-key": f.key });
const msg = h("div", { class: "msgs" });
const cards = V[f.key];
if (!Array.isArray(cards)) {
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
} else {
cards.forEach((c, i) => {
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
// Back to one card, the stored shape returns: a flat inverter stays flat.
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
put(f, null, V[f.key], true);
} }, W.remove) : null));
for (const s of f.fields) box.append(fieldRow(s, i, gid));
wrap.append(box);
});
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
put(f, null, V[f.key], true);
} }, "+ " + W.add_inverter));
}
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
return wrap;
}
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
function render() {
const host = $("#groups");
const y = window.scrollY;
host.replaceChildren();
ROWS.length = 0;
for (const g of M.layout.groups) {
const d = h("details", { class: "grp", "data-g": g.id },
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
d.open = OPEN.has(g.id);
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
const body = h("div", { class: "body" });
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
for (const p of g.parts) {
const pid = g.id + "." + p.id;
let box;
if (p.collapsed) {
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
box.open = OPEN.has(pid);
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
} else {
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
}
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
body.append(box);
}
if (g.id === "maintenance") {
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
h("div", { class: "row" },
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
}
d.append(body);
host.append(d);
}
renderSimple();
paint();
window.scrollTo(0, y);
}
/* ---------------- Settings, Simple (P3) ---------------- */
// The fields the layout marks `simple`, each in its own card with when it
// applies, and the batteries' switches and modes (runtime state, applied at
// once) drawn by app.js from /status. The Installer rows stay built but
// hidden, so the save bar counts every unsaved change in either view.
const chip = (applies) => h("span", { class: "chip " + (applies === "now" ? "now" : "rs") }, ROOT.applies[applies].name);
// A note with {kw} is filled from /status (app.js sends ux13:status).
function fillNotes() {
const st = window.UX13.status();
const mp = st && st.month_peak;
document.querySelectorAll("#simple [data-kw-note]").forEach((e) => {
const ok = mp && mp.peak_w != null;
e.hidden = !ok;
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
});
}
// OPT-11 P4: the solar forecast card shows the add-on's forecast line.
function fillSolar() {
const st = window.UX13.status();
const line = st && st.solar ? st.solar.line : "";
document.querySelectorAll("#simple [data-solar-note]").forEach((e) => {
e.textContent = line || "";
e.hidden = !line;
});
}
document.addEventListener("ux13:status", fillNotes);
document.addEventListener("ux13:status", fillSolar);
function simpleField(f) {
const row = fieldRow(f, null, "simple");
row.querySelector(".head").append(chip(f.applies));
const n = f.note && W.simple.note[f.note];
if (n && n.description.includes("{kw}")) {
row.append(h("div", { class: "small muted", "data-kw-note": n.description, hidden: true }));
} else if (n) {
// A plain note replaces the option's installer help on this view.
const help = row.querySelector(".help");
if (help) help.textContent = n.description;
}
if (f.note === "forecast") row.append(h("div", { class: "small muted", "data-solar-note": "", hidden: true }));
return h("div", { class: "card" }, row);
}
function renderSimple() {
const host = $("#simple");
host.replaceChildren();
if (mode !== "simple") return;
const cards = allFields().filter((f) => f.simple).map(simpleField);
const batts = h("div", {});
cards.splice(Math.min(1, cards.length), 0, h("div", { class: "card" },
h("div", { class: "row spread" }, h("div", { class: "lbl" }, W.simple.batteries.name), chip("now")), batts));
host.append(...cards, h("p", { class: "small muted", style: "margin:4px 2px" }, W.simple.more.description + " ",
h("button", { type: "button", class: "btn link", onclick: () => setMode("installer", true) }, W.simple.installer)));
window.UX13.batteries(batts);
fillNotes();
}
// `remember`: only the person's own pick of a view is kept for next time;
// a jump to Installer (a link, a setting to fix) is not.
function setMode(v, redraw, remember) {
mode = v;
if (remember) try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ }
document.querySelectorAll("#set-view [role=radio]").forEach((b) => {
const on = b.dataset.v === v;
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
});
$("#set-desc").textContent = v === "simple" ? W.simple.description : W.description;
$("#simple").hidden = v !== "simple";
["#set-search", "#groups", "#set-nomatch"].forEach((id) => { $(id).classList.toggle("vhide", v === "simple"); });
if (redraw && M && view === "settings") render();
}
document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => {
b.addEventListener("click", () => setMode(b.dataset.v, true, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = all[(i + d + all.length) % all.length];
setMode(j.dataset.v, true, true); j.focus();
});
});
setMode(mode, false);
window.UX13.settingsView = (v) => setMode(v, true);
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad: "×", warn: "!", info: "i" };
const RANK = { bad: 0, warn: 1, info: 2 };
function mine(r, m) {
if (m.field !== r.f.key) return false;
if (r.list || r.card == null) return true;
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = view === "setup" || mode === "simple" ? "" : $("#set-search").value.trim().toLowerCase();
// A message counts as shown only beside a row of the view on screen.
const onScreen = (r) => view === "setup" || (mode === "simple") === (r.gid === "simple");
const placed = new Set();
for (const r of ROWS) {
r.visible = visible(r.f);
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible && onScreen(r)) placed.add(i); return hit; })
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
r.tiers = new Set(msgs.map((m) => m.tier));
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
// A card list counts as one change when cards were added or removed;
// an edit inside a card counts on its own row.
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
r.el.classList.toggle("changed", r.changed && !r.list);
}
// Search: a row shows when it is visible and matches. A card list shows
// while any of its rows does.
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
let any = false;
document.querySelectorAll("#groups details.grp").forEach((d) => {
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
d.querySelectorAll(".part").forEach((p) => {
const inside = rows.filter((r) => p.contains(r.el));
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
});
const hit = rows.some((r) => !r.el.hidden);
any = any || hit;
d.hidden = !!q && !hit;
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
const shownRows = rows.filter((r) => r.visible && !r.list);
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
const changed = rows.filter((r) => r.changed).length;
d.querySelector(".gbadge").replaceChildren(...[
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
});
$("#set-nomatch").hidden = !q || any;
// A sentence shown beside any field is not repeated here.
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
if (DEVICE) other.push({ tier: "info", text: DEVICE });
$("#set-other").hidden = !other.length;
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
savebar();
}
function dirtyCount() {
const seen = new Set();
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
return seen.size;
}
function savebar() {
const n = V ? dirtyCount() : 0;
$("#savebar").hidden = !(view === "settings" && n);
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
}
$("#set-search").addEventListener("input", () => { if (V) paint(); });
/* ---------------- talking to the add-on ---------------- */
let vtimer = null, vseq = 0;
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
// or null when it failed or a newer check overtook it.
async function validate(extra) {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
const b = await r.json();
if (seq !== vseq) return null;
MSG = b.messages || [];
DEVICE = b.device_note || "";
STEPS = b.steps || {};
paint();
return b;
} catch (e) { return null; /* the next change tries again */ }
}
function banner(level, title, sub, button, onClick) {
const host = $("#set-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
function restart() {
ask(W.dialog, async () => {
try {
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
} catch (e) { /* the add-on stops answering while it restarts */ }
toast(W.toast.restarting.description);
});
}
async function load(after) {
let m;
try {
const r = await fetch("config/model?lang=" + lang, { cache: "no-store" });
if (!r.ok) throw new Error("model " + r.status);
m = await r.json();
if (!m.layout || !m.ui) throw new Error("no layout");
} catch (e) {
$("#groups").replaceChildren();
$("#set-search").hidden = true;
banner("bad", W.load_failed.name, W.load_failed.description);
return;
}
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
UNLOCKED.clear();
$("#set-search").hidden = false;
FILLED = {};
LIST = null;
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.fills) FILLED[f.fills] = f;
if (f.control === "inverter_list") LIST = f;
}
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
else banner();
if (view === "setup") drawStep(); else render();
validate();
}
function firstBad() {
if (view === "settings" && mode === "simple" && !ROWS.some((x) => x.gid === "simple" && x.tiers && x.tiers.has("bad") && x.visible)
&& ROWS.some((x) => x.tiers && x.tiers.has("bad") && x.visible)) setMode("installer", true);
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible && (view !== "settings" || (mode === "simple") === (x.gid === "simple")));
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
r.el.scrollIntoView({ block: "center" });
const input = r.el.querySelector("input,select,button");
if (input) input.focus({ preventScroll: true });
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves. `base` is
// the model the page opened: only what changed since then is written, onto
// what is stored now (UX-15 F2).
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
}
// The settings another surface changed (not this page's own edits).
function staleText(b) {
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
}
async function save() {
const btn = $("#sb-save");
btn.disabled = true; btn.textContent = W.savebar.saving;
try {
clearTimeout(vtimer);
await validate();
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
const { r, b } = await postSave();
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
banner("bad", W.stale.name, staleText(b));
return;
}
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
toast(W.toast.failed);
} catch (e) { toast(W.toast.failed); }
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
}
$("#sb-save").addEventListener("click", save);
// Discard reloads what is stored: after a stale save ORIG is no longer it.
$("#sb-discard").addEventListener("click", async () => {
await load(); toast(W.toast.discarded);
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
/* ---------------- the setup wizard (P4) ---------------- */
const WS = W.setup;
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
function redrawView() { if (view === "setup") drawStep(); else render(); }
function allFields() {
const out = [];
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
return out;
}
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
function watts(w, signed) {
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return (signed && w < 0 ? "-" : "") + n + " W";
}
function line(tier, text) {
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
}
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
// uipage.kettle() (test_setup.py grades that one) on the page.
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
function kettleOutcome(src, before, after) {
const d = after - before;
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
const o = src[d > 0 ? "up" : "down"];
return { say: o.say, sets: o.sets, d };
}
function kettleOk() {
const src = kettleSource();
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
}
async function readMeter(src) {
let total = 0;
for (const [key, sign] of src.read) {
if (!V[key]) return null;
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
if (!r || r.watts == null) return null;
total += sign * r.watts;
}
return total;
}
function kettleBox() {
const src = kettleSource();
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
const sig = kettleSig(src);
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
// A skip after the reading went down still says it went down (UX-15).
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
const busy = (b) => { b.disabled = true; };
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
read.addEventListener("click", async () => {
busy(read);
const sig = kettleSig(src); // the entity may have changed since drawing
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
const w = await readMeter(src);
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
else {
const o = kettleOutcome(src, k.before, w);
// `wrong`: the reading went the way no option can fix. A skip after
// that keeps saying so on the last step (review R2).
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
}
drawStep();
});
const row = h("div", { class: "row" }, read);
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
} }, WS.kettle.skip));
box.append(row);
return box;
}
// Step 6: the shadow switch, the checks, and Save and restart.
// The shadow switch goes only on the model the wizard posts, never into V,
// which the Installer view saves too (review R3).
function shadowValue() {
const s = M.setup.shadow;
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
}
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
function shadowRow() {
const lid = "wzs";
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
return h("div", { class: "f inline" },
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
h("div", { class: "ctl" }, b));
}
async function runChecks() {
const list = $("#wz-checks");
if (list) list.replaceChildren(line("info", WS.check.running.description));
clearTimeout(vtimer);
const b = await validate({ setup: true, model: posted() });
WZ.checked = b;
if (!list || !list.isConnected) return b;
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
const items = [], seen = new Set();
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
const bad = (b.messages || []).filter((m) => m.tier === "bad");
if (!bad.length) add("ok", WS.check.pass.description);
for (const m of bad) add("bad", m.text);
for (const m of b.setup || []) add(m.tier, m.text);
if (b.device_note) add("info", b.device_note);
const src = kettleSource();
if (!src) add("info", WS.kettle.self.description);
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
else if (kettleOk()) add("ok", WS.kettle.tested.description);
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
WZ.blocked = items.some(([t]) => t === "bad");
return b;
}
function finalBox() {
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
setTimeout(runChecks, 0);
return [h("div", { class: "subhead" }, WS.check.name),
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
shadowRow()];
}
function wzBanner(level, title, sub, button, onClick) {
const host = $("#wz-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
async function finish() {
const b = await runChecks();
if (!b) { toast(WS.check.failed.description); return; }
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
const shadow = shadowValue() === M.setup.shadow.on;
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
ask(dlg, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
const { r, b: body } = res;
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
if (r.status === 422) {
MSG = body.messages || []; STEPS = body.steps || {};
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
if (first) WZ.step = first;
drawStep(); toast(W.toast.blocked.description); firstBad();
return;
}
if (!r.ok) { toast(W.toast.failed); return; }
let restarted = true;
try {
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
restarted = rr.ok;
} catch (e) { /* the add-on stops answering while it restarts */ }
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
drawStep();
if (!restarted) await load("saved"); // still running: read what is stored
});
}
function drawDone() {
const w = WS[WZ.done];
$("#wz-count").textContent = "";
$("#wz-steps").replaceChildren();
$("#wz-desc").textContent = "";
$("#wz-nav").hidden = true;
ROWS.length = 0;
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
else wzBanner("ok", w.name, w.description);
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
}
function drawStep() {
if (!M) return;
if (WZ.done) { drawDone(); return; }
const S = M.setup, n = WZ.step, words = WS.steps[n];
$("#wz-nav").hidden = false;
$("#wz-title").textContent = words.name;
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
$("#wz-desc").textContent = words.description;
ROWS.length = 0;
const body = [];
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
if (n === 1) body.push(kettleBox());
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
if (n === S.final) body.push(...finalBox());
$("#wz-body").replaceChildren(...body);
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
paint();
}
$("#wz-back").addEventListener("click", () => {
if (WZ.step === 1) { location.hash = "#home"; return; }
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
});
$("#wz-next").addEventListener("click", async () => {
if (!M) return;
const btn = $("#wz-next");
btn.disabled = true;
try {
const n = WZ.step;
if (n === M.setup.final) { await finish(); return; }
clearTimeout(vtimer);
const b = await validate();
if (!b) { toast(W.toast.failed); return; }
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
} finally { btn.disabled = false; }
});
document.addEventListener("ux13:tab", (e) => {
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
});
// app.js chose the first tab before this file ran.
const sel = document.querySelector(".tab[aria-selected=true]");
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
// First run: opened with no route, on a site the add-on says is new
// (uipage.setup_needed), the page goes to Setup by itself.
else if (window.UX13.first) {
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
}
})();
+73 -68
View File
@@ -204,6 +204,7 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
@@ -262,9 +263,8 @@ def _minutes(value, field: str) -> int:
parts = text.split(":")
if len(parts) != 2:
raise ProfileInvalid(
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
f"guessed at - 7.5 could be 07:30 or 07:50.")
f"{field} is {value!r}. Write it as \"HH:MM\" in Belgian time, "
f"for example \"22:00\".")
try:
h, m = int(parts[0]), int(parts[1])
except ValueError:
@@ -272,9 +272,8 @@ def _minutes(value, field: str) -> int:
f"integers, e.g. \"07:00\"")
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
raise ProfileInvalid(
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
f"midnight at the end of the day.")
f"{field} is {value!r}, which is not a time of day. Use 00:00 to "
f"23:59, or \"24:00\" as an end time.")
return h * 60 + m
@@ -286,11 +285,8 @@ def _days(value, field: str) -> frozenset:
"""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Name the days this period applies to - "
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
f"defaulted to every day, because a Belgian night tariff that "
f"silently covered the weekend too would misprice 48 h a week with "
f"nothing reporting it.")
f"{field} is missing. Name the days, for example \"mon-fri\", "
f"\"sat,sun\" or \"all\".")
tokens = []
if isinstance(value, (list, tuple)):
for item in value:
@@ -367,10 +363,8 @@ def _price(value, field: str, means: str = None) -> float:
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
raise ProfileInvalid(
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
f"0.34 - which would make every hour look like an emergency. Same "
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. "
f"Write the price in EUR/kWh, for example 0.34 and not 34.")
return out
@@ -422,9 +416,8 @@ class StaticProfile:
self.option = option
if not self.periods:
raise ProfileInvalid(
f"{option} has no periods. A profile with no periods is "
f"not a fixed tariff, it is an absent one - leave the option "
f"empty to say that on purpose (§11.4 then applies).")
f"{option} has no periods. Add at least one, or leave the "
f"setting empty.")
_check_tiling(self.periods, option=option)
# -- what kind of tariff this is, DERIVED --------------------------------
@@ -558,10 +551,7 @@ def _raise_gap(weekday, start, end, option="static_profile"):
raise ProfileInvalid(
f"{option} leaves {DAY_NAMES[weekday]} "
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
f"price. Refused rather than defaulted: an invented price is one an "
f"optimiser would trade on, and §11.4 already says what a missing price "
f"means. Add a period covering it - remember a window written "
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
f"price. Add a period that covers them.")
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
@@ -569,9 +559,8 @@ def _raise_overlap(weekday, over, end, periods, option="static_profile"):
names = [p.name for p in periods if p.covers(weekday, start)]
raise ProfileInvalid(
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
f"one hour means one of them silently wins on list order. Narrow one of "
f"the windows or split the day selector.")
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Make the "
f"periods stop overlapping.")
def parse_static_profile(value, *, option: str = "static_profile",
@@ -609,28 +598,21 @@ def parse_static_profile(value, *, option: str = "static_profile",
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"with a \"periods\" list, on one line, e.g.\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty for a dynamic-only or price-free site.")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation, or leave it empty if you have no fixed "
f"tariff.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
if tz not in SUPPORTED_TZ:
raise ProfileInvalid(
f"{option}.timezone is {value.get('timezone')!r}. Only "
f"Europe/Brussels is supported: the shipped image carries no tz "
f"database (see app/price.py), so any other zone could not be "
f"honoured and would be silently treated as Brussels - which is "
f"wrong by an hour for part of the year.")
f"Europe/Brussels is supported.")
rows = value.get("periods")
if rows is None:
raise ProfileInvalid(
f"{option} has no \"periods\" list. Even a fully-fixed "
f"tariff is written as one period covering 00:00-24:00 all days "
f"- one shape for both products, so nothing downstream branches "
f"on which kind of site this is.")
f"{option} has no \"periods\" list. A fixed price is one "
f"period from 00:00 to 24:00 on all days.")
else:
rows = value
@@ -1137,18 +1119,39 @@ class TariffPlan:
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either.
"""
# UX-08: plain words, every figure in its own slot as before.
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return f"no prices, {self.read.reason}"
tag = {"provider": "the price feed", "static": "your fixed tariff",
"fallback": "your fixed tariff, because the feed is down"}.get(
self.origin, self.origin)
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
PriceProduct.REALTIME: "real-time prices",
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
# UX-10: the spacing in words, and no "not final yet": a plan is
# provisional exactly when it holds an estimated price, which the
# count already says.
gaps = self.spacings_s
return (f"{self.product.value} from {tag}, "
f"{self.n_points} points over {self.hours} h, "
f"{self.n_firm} firm / {self.n_carried} carried"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
if gaps else "")
+ (", provisional" if self.provisional else ""))
return (f"{what} from {tag}, "
f"{self.n_points} prices over {self.hours} hours, "
f"{self.n_firm} published and {self.n_carried} estimated"
+ (f", {spacing_words(gaps)}" if gaps else ""))
def spacing_words(gaps) -> str:
"""`(900, 3600)` -> `one every 15 minutes or every hour`."""
def one(s):
s = int(s)
if s == 3600:
return "every hour"
if s % 3600 == 0:
return f"every {s // 3600} hours"
if s % 60 == 0:
return f"every {s // 60} minutes"
return f"every {s} seconds"
return "one " + " or ".join(one(g) for g in gaps)
def _whole_hours(points) -> int:
@@ -1218,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
if profile is None:
return TariffPlan(
PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no "
"arbitrage. Self-consumption, peak shaving and reserve "
"management are unaffected"),
"no fixed tariff is set, so the plan is estimated and there "
"is no trading. Self-consumption, peak limiting and the "
"reserve are not affected"),
None, "none")
points = profile.series(since, until, provisional=provisional)
return TariffPlan(
@@ -1242,21 +1245,22 @@ def static_status(opts: dict, now: datetime):
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
_LOG.warning("static_profile cannot be used (%s), so there is no "
"cheap window. The start-up check should have refused "
"it.", e)
return False, f"Fixed tariff cannot be used: {e}"
if not plan.has_prices:
return False, "no static profile"
return False, "No fixed tariff set"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
f"{'cheap' if cheap else 'not cheap'} against the "
f"{MEAN_WINDOW_DAYS}-day average of "
f"{plan.mean_eur_kwh():.4f}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
@@ -1349,17 +1353,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
try:
profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable "
"(%s), so there is no tariff at all", e)
_LOG.warning("The price feed is down and the fixed tariff "
"(static_profile) cannot be used either (%s), so there is "
"no tariff at all.", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
_LOG.warning("The price feed failed and no fixed tariff is set, so "
"trading is off, while peak limiting and home use go on. "
"%s.", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
_LOG.warning("The price feed failed, so the fixed tariff is used "
"instead, marked as estimated, and trading is off. %s.",
read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
+42 -26
View File
@@ -222,6 +222,8 @@ class MigrationReport:
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
# The inverter list the moves were graded against, for `explain()`.
self.roster = []
@property
def ok(self) -> bool:
@@ -237,19 +239,27 @@ class MigrationReport:
# the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them
# changes when a second inverter is declared.
return self.skipped or (f"store already at schema "
f"{self.from_version}; no "
f"per-inverter migration needed")
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
f"namespaces: " + ", ".join(sorted(self.moved)))
return self.skipped or (f"the saved state is already at version "
f"{self.from_version}, so nothing moves")
# UX-10: plain sentences (test_messages.py part 6). Each problem is
# named by its key and what went wrong, and `problems`, `lost` and
# `misdirected` keep their full notes for the checks that read them.
n = len(self.moved)
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
f"per inverter: " + ", ".join(sorted(self.moved)) if n
else "moved nothing")
if self.kept:
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
if self.problems:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
out += ". Already in place: " + ", ".join(sorted(self.kept))
def keys(notes):
return ", ".join(note.split(" ", 1)[0] for note in notes)
other = [n for n in self.problems
if n not in self.lost and n not in self.misdirected]
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
[f"moved under an inverter not in the list {self.roster!r}: "
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
[f"read back differently {keys(other)}"] if other else [])
if parts:
out += ". Problems: " + ", and ".join(parts)
return out
@@ -328,8 +338,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
_LOG.error("Moving the saved state to one part per inverter lost "
"data, so it is not marked done: %s", rep.explain())
return rep
@@ -436,6 +446,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if roster is None:
return
names = [str(n) for n in roster]
rep.roster = names
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
#
@@ -469,8 +480,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
note = (f"{target} was moved under {unit!r}, which is not in the "
f"inverter list {names!r}, so nothing reads it")
rep.problems.append(note)
rep.misdirected.append(note)
@@ -536,14 +547,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
rep.skipped = ("one inverter, so it is read where it is and nothing "
"moves")
return rep
if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema "
f"{rep.from_version}; written by a multi-inverter "
f"version and not touched")
rep.skipped = (f"already at version {rep.from_version}, written for "
f"several inverters, so it is left as it is")
return rep
rep.ran = True
@@ -567,15 +576,21 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
# the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
_LOG.error("Moving the maintenance history to one part per inverter "
"did not work, so it is not marked done: %s", rep.explain())
return rep
STATE_PATH = "/data/state.json"
class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
def __init__(self, path: str | None = None):
self.path = path or STATE_PATH
self.data = dict(DEFAULTS)
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
self.fresh = False
self.load()
def load(self) -> None:
@@ -589,6 +604,7 @@ class Store:
self.data.get("phase"), self.data.get("last_completed"),
self.data.get("sign_tripped"))
except FileNotFoundError:
self.fresh = True
_LOG.info("no saved state, starting fresh")
except (json.JSONDecodeError, OSError) as err:
# A corrupt state file must not stop the controller: losing the
+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 -------------------------------------------------------
+455
View File
@@ -0,0 +1,455 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Solar"},
"inverters.modes": {"name": "Battery modes"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit", "description": "The add-on aims a little below your limit, to be safe."},
"safety.start": {"name": "Starting and stopping"},
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
"now": {"name": "Applies right away"}
},
"home": {
"app": {"name": "Battery add-on"},
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
"plan": {
"name": "Today's plan",
"description": "Point at a bar or a block for details, or tap it on a phone.",
"cheap": "Low price",
"dear": "High price",
"estimate": "Estimate",
"grid": "Charges from the grid",
"give": "Gives power",
"cycle": "Monthly battery cycle",
"peak": "Peak limit",
"house": "Follows the house",
"now": "now",
"time_to": "to",
"per_kwh": "per kWh",
"cheap_tip": "low for today",
"dear_tip": "high for today",
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words",
"average_tip": "hour average"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
"dialog": {
"cancel": "Cancel",
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
},
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
},
"choice_help": {
"meter_source": {
"off": {"description": "A Home Assistant entity that reads grid power in W, positive when you take from the grid."},
"ha_dsmr": {"description": "Two Home Assistant entities from the DSMR integration, one for import and one for export."},
"mqtt_p1": {"description": "The meter's readings arrive over MQTT on a topic you name."},
"ha_signed": {"description": "One Home Assistant entity that reads net grid power, positive for import."},
"homewizard_local": {"description": "The add-on reads a HomeWizard meter on your network, and can spot a stuck meter."}
}
},
"settings": {
"name": "Settings",
"description": "Every setting, grouped by task.",
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
"simple": {"description": "The choices a home owner makes.",
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}, "forecast": {"description": "With a forecast, a battery buys only what the sun will not give."}},
"batteries": {"name": "Batteries"},
"more": {"description": "Looking for the meter, inverters or prices?"},
"installer": "Open the installer view"},
"classic": "Classic settings",
"search": {"name": "Find a setting", "description": "Find a setting, for example peak or meter."},
"no_match": {"description": "No setting matches your search."},
"loading": "Loading the settings",
"load_failed": {"name": "Cannot read the settings", "description": "The add-on could not read its stored settings, so use Classic settings or the Configuration tab."},
"footer": {"description": "Every change is checked by the add-on's own start checks before it is saved."},
"to_fix": "to fix",
"to_check": "to check",
"changed": "changed",
"on": "On",
"off": "Off",
"not_set": "Not set",
"locked": "Locked",
"change": "Change",
"support": {"description": "These were measured on real hardware, so change them only when support asks."},
"picker": {
"choose": "Choose an entity",
"search": {"description": "Search by name or entity id."},
"all": "Show every entity",
"use": "Use this entity id",
"clear": "Clear",
"missing": "Not in Home Assistant",
"none": {"description": "Nothing matches, so check the entity in Developer Tools."}
},
"add_inverter": "Add an inverter",
"inverter": "Inverter",
"remove": "Remove",
"add_row": "Add a row",
"add_solar": "Add a solar inverter",
"periods": {"window": "Window", "from": "From", "to": "To", "days": "Days", "price": "Price", "add": "Add a time window"},
"reversed": "Reads negative",
"phase": "Phase",
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"region_year": "{year} figures",
"hour": "{hour}:00 UTC",
"preset": {"description": "Filled in by the region above, so choose your own figure to change it."},
"cycle": {"name": "Run a cycle by hand", "description": "The same actions as on Home, each asking first."},
"savebar": {"label": "Unsaved changes", "one": "change not saved", "many": "changes not saved", "discard": "Discard", "save": "Save", "saving": "Saving"},
"saved": {"name": "Saved, not running yet", "description": "The add-on runs the settings it started with until you restart it."},
"differs": {"name": "Stored settings are not running", "description": "Some stored settings differ from what runs now, so restart the add-on to apply them."},
"stale": {"name": "Changed somewhere else", "description": "These settings changed elsewhere while you edited, so save again to keep yours or discard to load theirs."},
"other": "Other messages",
"restart": "Restart now",
"dialog": {"name": "Restart the add-on?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Restart now"},
"toast": {
"saved": "Saved. Restart to apply.",
"discarded": "Changes discarded.",
"failed": "That did not work. Try again.",
"blocked": {"description": "Fix the settings marked in red, then save again."},
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
},
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
"derive": {
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"no_inverter": {"description": "Add at least one inverter."},
"flat_named": {"description": "Add a second inverter before naming this one."},
"no_source": {"description": "Choose where the dynamic prices come from."},
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
"no_price": {"description": "Enter a price per kWh."},
"no_periods": {"description": "Add at least one time window."},
"period_time": {"description": "Window {row}: write each time as hours and minutes, like 07:00 or 24:00."},
"period_zero": {"description": "Window {row} starts and ends at the same time, so change one of them."},
"period_price": {"description": "Window {row}: enter a price per kWh."},
"period_price_range": {"description": "Window {row}: enter the price in euro per kWh, like 0.34."},
"period_overlap": {"description": "Windows {a} and {b} both cover {day} {from} to {to}, so change one of them."},
"period_gap": {"description": "No window covers {day} {from} to {to}, so add one or widen one."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
},
"setup": {
"name": "Setup",
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
"step": "Step {n} of {total}",
"back": "Back",
"next": "Next",
"skip": "Next, or skip",
"finish": "Save and restart",
"not_now": "Not now",
"steps": {
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
},
"modes_later": {"description": "After setup, turn on battery modes to choose a mode for each battery on the Batteries screen."},
"kettle": {
"name": "Check the direction",
"description": "Read the meter, switch on a kettle, then read the meter again.",
"read": "Read the meter",
"again": "Read again",
"skip": "Skip this test",
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction before you start control."},
"tested": {"description": "The meter direction was tested with a kettle."},
"needed": {"description": "Do the kettle test first, or skip it."}
},
"check": {
"name": "What the add-on checked",
"running": {"description": "Checking with the add-on's own start checks."},
"pass": {"description": "The add-on's start checks pass."},
"failed": {"description": "The check did not run, so try again."},
"again": "Check again",
"blocked": {"description": "Fix the items marked in red, then check again."}
},
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
"home": "Open Home"
}
},
"home_words": {
"mode_house": "Follows the house",
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "cheap_hours": "Cheap hours only", "off": "Off"},
"attn_shadow": "Shadow mode is on",
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
"zero_held": "Held at 0 W",
"zero_going": "Going to 0 W",
"verdict": {
"held": "Held at 0 W",
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house"
},
"pill": {
"running": "Running",
"shadow": "Shadow mode",
"stopped": "Stopped",
"alarm": "Needs you now",
"warning": "Running, with a warning"
},
"banner_sub": {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"warning": "See Needs attention below."
},
"tile": {
"not_set_up": "Not set up",
"no_reading": "No reading",
"producing": "Producing",
"not_producing": "Not producing",
"needs_solar": "Needs a solar reading",
"not_adding_up": "Readings do not add up",
"using": "Using",
"taking": "Taking from the grid",
"feeding": "Feeding in",
"balanced": "Balanced",
"giving": "Giving power",
"charging": "Charging",
"resting": "Resting"
},
"now": {
"no_reading": "No reading from this battery.",
"would_rest": "{zero}. Would rest now.",
"would_give": "{zero}. Would give {kw} now.",
"would_charge": "{zero}. Would charge {kw} now.",
"held": "{zero} until the alarm clears.",
"stopped": "{zero} while control is stopped.",
"cycle_drain": "Monthly cycle: emptying.",
"cycle_charge": "Monthly cycle: refilling.",
"cycle_hold": "Monthly cycle: holding full.",
"cycle": "Monthly cycle under way.",
"giving": "Giving {kw} now.",
"charging": "Charging {kw} now.",
"resting": "Resting now.",
"off_resting": "Off. Resting now.",
"off_peak": "Off, but gives {kw} for the peak limit.",
"off_going": "Off. Still at {kw}, going to 0 W."
},
"why": {
"no_reading": "No reading, so the add-on cannot tell.",
"held": "An alarm stopped control. See Needs attention.",
"stopped": "Control is stopped.",
"shadow": "Shadow mode: the add-on only shows what it would do.",
"cycle": "The monthly cycle keeps its level reading right.",
"cycle_mode": "The monthly cycle comes first. Its mode resumes after.",
"off": "You switched it off.",
"off_peak": "The peak limit comes before Off. It rests again after.",
"refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.",
"refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.",
"peak": "The peak limit needs power from the batteries.",
"modes_off": "Battery modes are off, so it follows the house.",
"solar_first": "Solar first: it gets spare solar before the others.",
"leftover": "It gets the solar that Solar first batteries cannot take.",
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over.",
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
"grid_charge": "Prices are low, so it charges from the grid.",
"rests_charge": "Another battery charges from the grid, so it rests.",
"selling": "Prices are high, so it sells to the grid.",
"expensive": "Prices are high, so it covers the house.",
"saves": "Saves its power for {at}.",
"saves_later": "Saves its power for higher prices.",
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
"off": "Off, so no monthly cycle today.",
"house": "Follows the house.",
"house_would": "Would follow the house.",
"stopped": "Nothing planned while control is stopped.",
"held": "Nothing planned until the alarm clears."
},
"head": {
"held": "Every inverter is at 0 W until the alarm clears.",
"held_going": "Every inverter goes to 0 W until the alarm clears.",
"stopped": "Control is stopped, so nothing is planned.",
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
"shadow": "Shadow mode: this is what the add-on would do.",
"cycle_would": "A battery would run its monthly cycle today.",
"trade_would": "Would charge when cheapest and give power when dearest.",
"house_would": "Would charge from spare solar and cover the house."
},
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_why": "Lowest published price, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
"give_what_would": "Would give up to {kwh}, to the house first.",
"give_why": "Highest published price, {eur} per kWh.",
"sell_what": "Sells up to {kwh}, to the house first.",
"sell_what_would": "Would sell up to {kwh}, to the house first.",
"cheap_why": "Cheapest hours, about {eur} per kWh.",
"dear_why": "Expensive hours, about {eur} per kWh.",
"give_amount": "Worth up to {eur} at that price.",
"cycle_start": "Starts the monthly battery cycle.",
"cycle_start_would": "Would start the monthly battery cycle.",
"cycle_now": "Runs the monthly battery cycle now.",
"cycle_now_would": "Would run the monthly battery cycle now.",
"cycle_why": "Empties, refills and holds full, so the level reads right.",
"cycle_why_would": "Would empty, refill and hold full, so the level reads right.",
"cycle_amount_unknown": "How long depends on the battery.",
"cycle_amount": "Takes about {hours} hours.",
"peak_what": "Gives power to stay under {kw}.",
"peak_what_would": "Would give power to stay under {kw}.",
"peak_why": "The peak limit comes before everything else.",
"house_what": "Follows the house.",
"house_what_would": "Would follow the house.",
"house_why": "Charges from spare solar, gives power when the house needs it.",
"house_why_would": "Would charge from spare solar, give power when the house needs it.",
"house_amount": "Amounts depend on the sun and the house."
},
"no_trade": {
"not_covered": "No trading on price: nothing measures the 15-minute peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown."
},
"tomorrow_trade": {
"grid": "Tomorrow: charges at {at}, {eur} per kWh.",
"grid_would": "Tomorrow: would charge at {at}, {eur} per kWh.",
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
},
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
"none": "No prices for today."
},
"tomorrow": {
"known": "Tomorrow's prices are in.",
"unknown": "Tomorrow's prices usually arrive after 13:00."
},
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
"own": "Levies and VAT: your own figure."
},
"peak_not_covered": "No peak limit: this meter does not measure 15-minute periods.",
"control": {
"shadow": "Shadow mode: every inverter stays at 0 W.",
"running": "Control is running.",
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": "Meter reading may be stuck",
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": "Power request too high",
"not_keeping_up": "Battery is not keeping up",
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": "Inverters working against each other",
"delivery": "An inverter is not delivering",
"estop": "Emergency stop",
"pv": "Solar reading",
"none": "Inverter connection"
},
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
}
}
+378
View File
@@ -0,0 +1,378 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Waar de meting van het net vandaan komt."},
"inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."},
"tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."},
"safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."},
"maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."},
"tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Zonnepanelen"},
"inverters.modes": {"name": "Batterijmodi"},
"tariff.selling": {"name": "Laden en verkopen op prijs"},
"tariff.feed": {"name": "Details van de prijsfeed"},
"safety.peak": {"name": "Piekgrens", "description": "De add-on mikt iets onder uw grens, voor de zekerheid."},
"safety.start": {"name": "Starten en stoppen"},
"safety.stops": {"name": "Veiligheidsstops", "description": "Gemeten op echte hardware, dus laat ze zoals ze zijn."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Omvormers van de zonnepanelen", "description": "De vermogenssensor van elke zonne-omvormer, die de add-on bijhoudt maar niet gebruikt om te regelen."},
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
},
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
"tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
"plan": {
"name": "Plan voor vandaag",
"description": "Wijs een staaf of blok aan voor details, of tik erop op een telefoon.",
"cheap": "Lage prijs",
"dear": "Hoge prijs",
"estimate": "Schatting",
"grid": "Laadt van het net",
"give": "Levert stroom",
"cycle": "Maandelijkse batterijcyclus",
"peak": "Piekgrens",
"house": "Volgt het huis",
"now": "nu",
"time_to": "tot",
"per_kwh": "per kWh",
"cheap_tip": "laag voor vandaag",
"dear_tip": "hoog voor vandaag",
"estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden",
"average_tip": "gemiddelde van het uur"
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
"dialog": {
"cancel": "Annuleren",
"stop": {"name": "Regeling stoppen?", "description": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.", "yes": "Regeling stoppen"},
"start": {"name": "Regeling starten?", "description": "De add-on regelt de batterijen weer en heft een veiligheidsstop op.", "yes": "Regeling starten"},
"maint_start": {"name": "Batterijcyclus starten?", "description": "De batterij loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"},
"maint_abort": {"name": "Batterijcyclus stoppen?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}
},
"toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."},
"offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."},
"setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"},
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"today": "Vandaag", "why": "Waarom",
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
},
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
"choice_help": {
"meter_source": {
"off": {"description": "Een Home Assistant-entiteit met het netvermogen in W, positief als u van het net afneemt."},
"ha_dsmr": {"description": "Twee Home Assistant-entiteiten van de DSMR-integratie, een voor afname en een voor injectie."},
"mqtt_p1": {"description": "De meetwaarden komen via MQTT binnen op een topic dat u opgeeft."},
"ha_signed": {"description": "Eén Home Assistant-entiteit met het nettovermogen, positief bij afname."},
"homewizard_local": {"description": "De add-on leest een HomeWizard-meter op uw netwerk en merkt een vastgelopen meter op."}
}
},
"settings": {
"name": "Instellingen",
"description": "Elke instelling, per taak.",
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
"simple": {"description": "De keuzes die een eigenaar maakt.",
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}, "forecast": {"description": "Met een prognose koopt een batterij alleen wat de zon niet geeft."}},
"batteries": {"name": "Batterijen"},
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
"installer": "De installateursweergave openen"},
"classic": "Klassieke instellingen",
"search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."},
"no_match": {"description": "Geen instelling past bij uw zoekopdracht."},
"loading": "Instellingen laden",
"load_failed": {"name": "Instellingen niet leesbaar", "description": "De add-on kon zijn bewaarde instellingen niet lezen, dus gebruik Klassieke instellingen of het tabblad Configuratie."},
"footer": {"description": "Elke wijziging gaat door de startcontroles van de add-on voor ze bewaard wordt."},
"to_fix": "op te lossen",
"to_check": "na te kijken",
"changed": "gewijzigd",
"on": "Aan",
"off": "Uit",
"not_set": "Niet ingesteld",
"locked": "Vergrendeld",
"change": "Wijzigen",
"support": {"description": "Deze zijn gemeten op echte hardware, dus wijzig ze alleen als support erom vraagt."},
"picker": {"choose": "Kies een entiteit", "search": {"description": "Zoek op naam of entiteit-id."}, "all": "Alle entiteiten tonen", "use": "Deze entiteit-id gebruiken", "clear": "Wissen", "missing": "Niet in Home Assistant", "none": {"description": "Niets gevonden, dus kijk de entiteit na in Ontwikkelhulpmiddelen."}},
"add_inverter": "Omvormer toevoegen",
"inverter": "Omvormer",
"remove": "Verwijderen",
"add_row": "Regel toevoegen",
"add_solar": "Zonne-omvormer toevoegen",
"periods": {"window": "Tijdvak", "from": "Van", "to": "Tot", "days": "Dagen", "price": "Prijs", "add": "Tijdvak toevoegen"},
"reversed": "Meet negatief",
"phase": "Fase",
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"region_year": "cijfers van {year}",
"hour": "{hour}:00 UTC",
"preset": {"description": "Ingevuld door de regio hierboven, dus kies een eigen bedrag om het te wijzigen."},
"cycle": {"name": "Een cyclus met de hand starten", "description": "Dezelfde acties als op Start, die elk eerst om bevestiging vragen."},
"savebar": {"label": "Niet bewaarde wijzigingen", "one": "wijziging niet bewaard", "many": "wijzigingen niet bewaard", "discard": "Verwerpen", "save": "Bewaren", "saving": "Bezig met bewaren"},
"saved": {"name": "Bewaard, nog niet actief", "description": "De add-on werkt met de instellingen waarmee hij startte tot u hem herstart."},
"differs": {"name": "Bewaarde instellingen zijn niet actief", "description": "Sommige bewaarde instellingen verschillen van wat nu draait, dus herstart de add-on om ze toe te passen."},
"stale": {"name": "Elders gewijzigd", "description": "Deze instellingen zijn elders gewijzigd terwijl u bewerkte, dus bewaar opnieuw om de uwe te houden of verwerp om de andere te laden."},
"other": "Andere meldingen",
"restart": "Nu herstarten",
"dialog": {"name": "Add-on herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Nu herstarten"},
"toast": {"saved": "Bewaard. Herstart om toe te passen.", "discarded": "Wijzigingen verworpen.", "failed": "Dat lukte niet. Probeer opnieuw.", "blocked": {"description": "Los de rood gemarkeerde instellingen op en bewaar opnieuw."}, "restarting": {"description": "Herstart gevraagd, dus deze pagina antwoordt even niet."}},
"tier": {"bad": "Oplossen", "warn": "Nakijken", "info": "Opmerking", "ok": "In orde"},
"derive": {
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"no_inverter": {"description": "Voeg minstens één omvormer toe."},
"flat_named": {"description": "Voeg een tweede omvormer toe voor u deze een naam geeft."},
"no_source": {"description": "Kies waar de dynamische prijzen vandaan komen."},
"custom_profile": {"description": "Deze tijdvakken kunnen alleen op het tabblad Configuratie gewijzigd worden."},
"no_price": {"description": "Vul een prijs per kWh in."},
"no_periods": {"description": "Voeg minstens één tijdvak toe."},
"period_time": {"description": "Tijdvak {row}: schrijf elk tijdstip als uren en minuten, zoals 07:00 of 24:00."},
"period_zero": {"description": "Tijdvak {row} begint en eindigt op hetzelfde uur, dus wijzig een van beide."},
"period_price": {"description": "Tijdvak {row}: vul een prijs per kWh in."},
"period_price_range": {"description": "Tijdvak {row}: vul de prijs in euro per kWh in, zoals 0,34."},
"period_overlap": {"description": "Tijdvakken {a} en {b} vallen allebei op {day} {from} tot {to}, dus wijzig er een."},
"period_gap": {"description": "Geen tijdvak dekt {day} {from} tot {to}, dus voeg er een toe of maak er een breder."},
"unknown": {"description": "De pagina kan deze keuze niet bewaren, dus laad de pagina opnieuw."}
},
"setup": {
"name": "Installatie",
"description": "Voor installateurs: zes stappen naar een werkende installatie, en elke stap kan later gewijzigd worden onder Instellingen.",
"done_before": {"description": "Deze installatie is al ingesteld, dus een wijziging hier vervangt de bewaarde instellingen."},
"step": "Stap {n} van {total}",
"back": "Terug",
"next": "Volgende",
"skip": "Volgende, of overslaan",
"finish": "Bewaren en herstarten",
"not_now": "Niet nu",
"steps": {
"1": {"name": "Uw meter", "description": "De add-on stuurt de netmeting naar nul, dus eerst is uw meter nodig."},
"2": {"name": "Omvormers en batterijen", "description": "Eén kaart per omvormer met een batterij, met zijn entiteiten in Home Assistant."},
"3": {"name": "Zonnepanelen", "description": "Niet verplicht, dus sla deze stap over als de add-on de zonneproductie niet moet bijhouden."},
"4": {"name": "Prijzen", "description": "Wat u per kWh betaalt, zodat de batterijen laden als stroom goedkoop is."},
"5": {"name": "Piekgrens", "description": "Het net blijft onder deze grens, gemiddeld over elk kwartier."},
"6": {"name": "Nakijken en starten", "description": "De add-on kijkt alles na met zijn eigen startcontroles en herstart dan."}
},
"modes_later": {"description": "Zet na de installatie batterijmodi aan om per batterij een modus te kiezen op het scherm Batterijen."},
"kettle": {
"name": "Richting nakijken",
"description": "Lees de meter af, zet een waterkoker aan en lees de meter opnieuw af.",
"read": "Meter aflezen",
"again": "Opnieuw aflezen",
"skip": "Deze test overslaan",
"before": {"description": "De meter geeft nu {w} aan, dus zet een waterkoker aan en druk op Opnieuw aflezen."},
"unread": {"description": "De meter is nu niet leesbaar, dus kijk de entiteit na en probeer opnieuw."},
"none": {"description": "De meting veranderde maar {d}, dus zet de waterkoker aan en lees opnieuw af."},
"ok": {"description": "Goed, de meting steeg met {d} toen de waterkoker aanging."},
"flip": {"description": "De meting daalde met {d}, dus de add-on keert het teken voor u om."},
"wrong_signed": {"description": "De meting daalde met {d}, dus kies hierboven “Eén entiteit voor netvermogen”, die het kan omkeren."},
"wrong_dsmr": {"description": "De meting daalde met {d}, dus kijk na of afname en injectie niet omgewisseld zijn."},
"self": {"description": "De add-on leest deze meter zelf, dus Start toont de richting na de herstart."},
"skipped": {"description": "De richting van de meter is niet getest, dus kijk ze na op Start na de herstart."},
"skipped_wrong": {"description": "Bij de waterkokertest daalde de meting, dus kijk de richting na in stap 1 voor u de regeling start."},
"skipped_down": {"description": "De meting daalde toen de waterkoker aanging, dus kijk de richting van de meter na voor u de regeling start."},
"tested": {"description": "De richting van de meter is getest met een waterkoker."},
"needed": {"description": "Doe eerst de waterkokertest, of sla hem over."}
},
"check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}},
"dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"},
"dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_setup_live": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en dan gelden ze.", "yes": "Bewaren en herstarten"},
"done": {"name": "Installatie is klaar", "description": "De add-on herstart nu in schaduwmodus, dus open Start over een minuut om te zien wat hij zou doen."},
"done_live": {"name": "Installatie is klaar", "description": "De add-on herstart nu, dus open Start over een minuut om de regeling te starten."},
"restart_failed": {"name": "Bewaard, nog niet actief", "description": "De herstart lukte niet, dus druk op Nu herstarten om de instellingen toe te passen."},
"home": "Start openen"
}
},
"home_words": {
"mode_house": "Volgt het huis",
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "cheap_hours": "Alleen goedkope uren", "off": "Uit"},
"attn_shadow": "Schaduwmodus staat aan",
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
"zero_held": "Op 0 W gehouden",
"zero_going": "Gaat naar 0 W",
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."},
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
"now": {
"no_reading": "Geen meting van deze batterij.",
"would_rest": "{zero}. Zou nu rusten.",
"would_give": "{zero}. Zou nu {kw} leveren.",
"would_charge": "{zero}. Zou nu {kw} laden.",
"held": "{zero} tot het alarm weg is.",
"stopped": "{zero} zolang de regeling gestopt is.",
"cycle_drain": "Maandelijkse cyclus: leegmaken.",
"cycle_charge": "Maandelijkse cyclus: opladen.",
"cycle_hold": "Maandelijkse cyclus: vol houden.",
"cycle": "Maandelijkse cyclus bezig.",
"giving": "Levert nu {kw}.",
"charging": "Laadt nu {kw}.",
"resting": "Rust nu.",
"off_resting": "Uit. Rust nu.",
"off_peak": "Uit, maar levert {kw} voor de piekgrens.",
"off_going": "Uit. Nog {kw}, gaat naar 0 W."
},
"why": {
"no_reading": "Geen meting, dus de add-on weet het niet.",
"held": "Een alarm stopte de regeling. Zie Vraagt aandacht.",
"stopped": "De regeling is gestopt.",
"shadow": "Schaduwmodus: de add-on toont alleen wat hij zou doen.",
"cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.",
"cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.",
"off": "U hebt haar uitgezet.",
"off_peak": "De piekgrens gaat voor op Uit. Daarna rust ze weer.",
"refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.",
"refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.",
"peak": "De piekgrens vraagt vermogen van de batterijen.",
"modes_off": "Batterijmodi staan uit, dus ze volgt het huis.",
"solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.",
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
"expensive": "De prijs is hoog, dus ze dekt het huis.",
"saves": "Spaart haar stroom voor {at}.",
"saves_later": "Spaart haar stroom voor hogere prijzen.",
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
"house": "Volgt het huis.",
"house_would": "Zou het huis volgen.",
"stopped": "Niets gepland zolang de regeling gestopt is.",
"held": "Niets gepland tot het alarm weg is."
},
"head": {
"held": "Elke omvormer staat op 0 W tot het alarm weg is.",
"held_going": "Elke omvormer gaat naar 0 W tot het alarm weg is.",
"stopped": "De regeling is gestopt, dus er is niets gepland.",
"cycle": "Een batterij doet vandaag zijn maandelijkse cyclus.",
"trade": "Laadt in het goedkoopste uur, levert stroom in het duurste.",
"house": "Laadt met zonne-overschot en dekt het huis.",
"shadow": "Schaduwmodus: dit is wat de add-on zou doen.",
"cycle_would": "Een batterij zou vandaag zijn maandelijkse cyclus doen.",
"trade_would": "Zou laden als het goedkoopst is en leveren als het duurst is.",
"house_would": "Zou laden met zonne-overschot en het huis dekken."
},
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
"dear_why": "Dure uren, ongeveer {eur} per kWh.",
"give_amount": "Tot {eur} waard aan die prijs.",
"cycle_start": "Start de maandelijkse batterijcyclus.",
"cycle_start_would": "Zou de maandelijkse batterijcyclus starten.",
"cycle_now": "Doet nu de maandelijkse batterijcyclus.",
"cycle_now_would": "Zou nu de maandelijkse batterijcyclus doen.",
"cycle_why": "Maakt leeg, laadt op en houdt vol, zodat het laadniveau klopt.",
"cycle_why_would": "Zou leegmaken, opladen en vol houden, zodat het laadniveau klopt.",
"cycle_amount_unknown": "Hoe lang hangt af van de batterij.",
"cycle_amount": "Duurt ongeveer {hours} uur.",
"peak_what": "Levert stroom om onder {kw} te blijven.",
"peak_what_would": "Zou stroom leveren om onder {kw} te blijven.",
"peak_why": "De piekgrens gaat voor alles.",
"house_what": "Volgt het huis.",
"house_what_would": "Zou het huis volgen.",
"house_why": "Laadt met zonne-overschot, levert als het huis vraagt.",
"house_why_would": "Zou laden met zonne-overschot, leveren als het huis vraagt.",
"house_amount": "Hoeveel hangt af van de zon en het huis."
},
"no_trade": {
"not_covered": "Geen handel op prijs: niets meet de kwartierpiek.",
"no_prices": "Nog geen handel op prijs: er zijn geen prijzen binnen.",
"peak": "Nu geen handel op prijs: de piekgrens gaat voor.",
"not_firm": "Nog geen handel op prijs: de prijzen zijn niet gepubliceerd.",
"not_worth": "Vandaag geen handel op prijs. Het prijsverschil is te klein.",
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
},
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
"meter_frozen": "Meter staat mogelijk vast",
"freeze_check": "Controle op vastgelopen meter",
"month_peak": "Piek van deze maand",
"peak_guard": "Piekgrens",
"peak_forecast": "Prognose voor de piekgrens",
"sensor": "Metingen van de batterij",
"control": "Regeling van de omvormer",
"power_too_high": "Vermogensvraag te hoog",
"not_keeping_up": "Batterij kan niet volgen",
"write": "Vermogensvraag niet aanvaard",
"direction": "Vermogen de verkeerde kant op",
"site_direction": "Vermogen de verkeerde kant op",
"circulating": "Omvormers werken elkaar tegen",
"delivery": "Een omvormer levert niet",
"estop": "Noodstop",
"pv": "Meting van de zonnepanelen",
"none": "Verbinding met de omvormer"
},
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
}
}
+347
View File
@@ -0,0 +1,347 @@
"""UX-13: the new page's layout, written once - screen, group, field.
UI code names option keys only here and in uimodel.py, which holds the
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
schema, rules in validate.py. test_uilayout.py fails CI when an option in
config.yaml has no place here.
A field's `key` is an option key, `inverters.<field>` for one inverter in the
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
The key is also its text key: label and help are translations/*.yaml's entry
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
"""
from dataclasses import dataclass
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
NOW = "now" # runtime state (OPT-11's battery switches), none yet
# Controls are names, not string literals, so a key is never followed by a
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
"number", "text", "select", "locked", "inverter_list", "solar_list")
PERIOD_LIST = "period_list" # P5: time windows, each with its own price
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_LIST)
# What an entity picker offers first. `power_w` is a power sensor the add-on
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
SHOW_OPS = ("==", "!=", "in", ">", "<")
@dataclass(frozen=True)
class Field:
key: str
control: str
unit: str = ""
scale: int = 1 # shown = stored / scale (the peak limit in kW)
step: float = 0 # slider or number step, stored units; 0 = 1
kind: str = "" # entity picker filter, one of ENTITY_KINDS
show: tuple = () # ((name, op, value), ...), all must hold
applies: str = RESTART
simple: bool = False # also on Settings, Simple
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
mirror: str = "" # a choice also copied to this field, if offered
fills: str = "" # a preset choice fills this field (uimodel)
note: str = "" # P3: a line under it on Settings, Simple
@property
def text(self) -> str:
"""Where the label and help live: `translations` or `ui_text`."""
return "ui_text" if self.key.startswith("ui.") else "translations"
@dataclass(frozen=True)
class Part:
id: str # "" is the group's main part
fields: tuple
collapsed: bool = False
@dataclass(frozen=True)
class Group:
id: str
parts: tuple
support_only: bool = False # collapsed, with a "for support" warning
def _src(*values):
return (("meter_source", "in", values),)
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
_MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
_LISTED = (("inverter_shape", "!=", "flat"),)
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
ROSTER_FIELDS = (
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
Field("inverters.batt_invert", TOGGLE, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
wizard=2),
)
GROUPS = (
Group("meter", (
Part("", (
Field("meter_source", CHOICE, wizard=1),
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
wizard=1),
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
Field("p1_net_entity", ENTITY, kind="power_w",
show=_src("ha_signed"), wizard=1),
Field("p1_import_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_export_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
Field("meter_phases", SEGMENTED, wizard=1),
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_signed") + _PHASES, wizard=1),
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
)),
Part("timing", (
Field("meter_max_age_s", SLIDER, "s", step=5),
Field("meter_poll_s", SLIDER, "s",
show=_src("homewizard_local")),
), collapsed=True),
)),
Group("inverters", (
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
# pv.py reads each entity's own unit, so a kW sensor is fine here.
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
wizard=3),)),
# OPT-11: the switch that turns battery modes on. Each battery's mode
# and on/off switch are runtime state on the Batteries screen.
Part("modes", (
Field("battery_modes_enabled", TOGGLE),
# OPT-11 P4: the solar forecast; today's also on Settings, Simple.
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
note="forecast"),
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
Field("solar_trust_pct", SLIDER, "%", step=5),
)),
)),
Group("tariff", (
Part("", (
# A contract without a feed is also the price used when a feed
# is down, so the fallback follows it (uimodel._tariff_encode).
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
Field("price_formula", TEXT,
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
wizard=4),
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, wizard=4,
fills="price_taxes_eur_kwh"),
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
wizard=4),
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
show=_DYNAMIC, wizard=4),
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
# OPT-11: derived from the batteries' selling switches once battery
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
Field("arbitrage_enabled", TOGGLE),
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
Field("arbitrage_soc_floor", SLIDER, "%"),
Field("arbitrage_soc_ceiling", SLIDER, "%"),
)),
Part("feed", (
Field("price_feed_url", TEXT, show=_DYNAMIC),
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
Field("price_now_entity", ENTITY, kind="any"),
Field("price_avg_entity", ENTITY, kind="any"),
), collapsed=True),
)),
Group("safety", (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5, note="month_peak"),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
note="shadow"),
Field("auto_start", TOGGLE),
Field("estop_fitted", TOGGLE),
Field("stale_input_s", SLIDER, "s"),
Field("meter_freeze_s", SLIDER, "s", step=30,
show=_src("homewizard_local")),
)),
Part("stops", (
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("site_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("site_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
), collapsed=True),
)),
Group("maintenance", (
Part("", (
Field("maintenance_enabled", TOGGLE),
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
Field("maintenance_start_hour", SELECT, show=_MAINT),
Field("maintenance_discharge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_charge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
Field("maintenance_hold_min", SLIDER, "min", step=5,
show=_MAINT),
)),
Part("support", (Field("log_level", SELECT),)),
)),
Group("tuning", (
Part("", (
Field("gain", LOCKED, step=0.05),
Field("slew_w", LOCKED, "W", step=50),
Field("deadband_w", LOCKED, "W"),
Field("target_grid_w", LOCKED, "W"),
Field("step_w", LOCKED, "W"),
Field("saturation_w", LOCKED, "W", step=10),
Field("saturation_cycles", LOCKED),
Field("integrator_max_w", LOCKED, "W", step=100),
Field("heartbeat_s", LOCKED, "s"),
Field("cycle_floor_s", LOCKED, "s"),
)),
), support_only=True),
)
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
# --------------------------------------------------------------------------- #
WIZARD_STEPS = 6
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
# read before and after a kettle is switched on, each with the sign it adds to
# the grid reading, and what each direction means as (outcome, fields it
# sets). Sets None: the options cannot fix it, so the step is not passed. A
# source not listed is read by the add-on itself, so the page cannot test it.
KETTLE_SOURCE = "meter_source"
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
KETTLE = {
"off": ((("meter_entity", 1),),
{"up": ("ok", {"meter_invert": False}),
"down": ("flip", {"meter_invert": True})}),
"ha_signed": ((("p1_net_entity", 1),),
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
}
# Step 6 starts the site in shadow mode unless the installer switches it off:
# (field, value when on, value when off).
SHADOW = ("shadow_mode", "on", "auto")
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
"soc_entity": "inverters",
"batt_entity": "inverters",
"batt_invert": "inverters",
"max_w": "inverters",
"bms_soc_floor": "inverters",
"pv_entity": "ui.solar", # one solar row, stored flat
"pv_entities": "ui.solar",
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
"static_profile": "ui.fallback", # built from the price fields
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
def fields():
"""Every field once, in page order, roster fields after `inverters`."""
for group in GROUPS:
for part in group.parts:
for field in part.fields:
yield group, part, field
if field.control == INVERTER_LIST:
for sub in ROSTER_FIELDS:
yield group, part, sub
def field(key: str) -> Field | None:
return next((f for _g, _p, f in fields() if f.key == key), None)
def visible(fld: Field, model: dict) -> bool:
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
for name, op, value in fld.show:
have = model.get(name)
if op == "==" and have != value:
return False
if op == "!=" and have == value:
return False
if op == "in" and have not in value:
return False
try:
if op == ">" and not float(have) > value:
return False
if op == "<" and not float(have) < value:
return False
except (TypeError, ValueError):
return False
return True
def range_of(key: str, schema_ui: list):
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
Never typed here: config.yaml is the one place a range is written. A range
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
path = key.split(".")
nodes = schema_ui or []
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
node = next((n for n in nodes if n.get("name") == path[-1]), None)
if not node or "lengthMin" not in node or "lengthMax" not in node:
return None
return node["lengthMin"], node["lengthMax"]
+488
View File
@@ -0,0 +1,488 @@
"""UX-13: stored options <-> the page model, for the keys the page derives.
`options_to_ui()` turns the stored blob into what the page edits: always a list
of inverter cards, solar rows with a "reads negative" toggle, a contract and
simple prices or time windows (P5) instead of `static_profile` JSON, and a
region preset instead of the levies figure. `ui_to_options()` turns it back, so the validator and the
Supervisor always see the real blob.
The rule that makes the round trip exact: the model carries the stored values
of the derived keys (`keep`). A derivation whose page fields still read what
`keep` decodes to writes `keep` back untouched, and only an edited one is
re-encoded. So a blob the page cannot express (a `static_profile` with odd
windows, a malformed roster) survives a save of some other field unchanged.
See DECISIONS/UX-13-one-layout-table.md.
"""
import copy
import json
import re
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH
from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid,
parse_static_profile)
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
"REGION_PRESETS", "PERIOD_DAYS",
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say. `code` names
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
page field it is shown beside."""
def __init__(self, text, code="", field="", **values):
super().__init__(text)
self.code, self.field = code, field
self.values = values # the sentence's {placeholders} (P5)
self.keys = () # the option keys the failed derivation writes
# The page model's inverter count, which show conditions read, and what adding
# a card sets: a second inverter turns the one flat inverter into a list.
COUNT = "inverter_count"
GROWN = {"inverter_shape": "list"}
# --------------------------------------------------------------------------- #
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
# residential card, Flanders, September 2026, VAT included). Wallonia and
# Brussels have a day and a night band (config.yaml) and join with sourced
# figures; until then those sites choose "own".
# --------------------------------------------------------------------------- #
REGION_PRESETS = {
"flanders": {
"year": 2026,
"taxes_eur_kwh": round(sum(v for _n, v in
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
"taxes_profile": "",
},
}
OWN = "own"
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
"max_w", "bms_soc_floor")
_TOL = 1e-9
def _blank(v) -> bool:
return v is None or (isinstance(v, str) and not v.strip())
def _num(v):
try:
return None if _blank(v) or isinstance(v, bool) else float(v)
except (TypeError, ValueError):
return None
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
def _roster_decode(o):
entries = o.get("inverters")
if entries is None or entries == "" or entries == []:
card = {k: o.get(k) for k in _ROSTER_FLAT}
card.update(name="", capacity_kwh=None)
return {"inverter_shape": "flat", "inverters": [card]}
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
return {"inverter_shape": "list",
"inverters": [dict(e) for e in entries]}
# Not something a card can show: edited in the Configuration tab only.
return {"inverter_shape": "raw", "inverters": None}
def _roster_encode(m, keep):
out = {}
shape, cards = m.get("inverter_shape"), m.get("inverters")
if shape == "raw":
raise DeriveError("the inverter list is edited in the Configuration tab",
"raw_roster", "inverters")
if not cards:
raise DeriveError("at least one inverter is needed", "no_inverter",
"inverters")
if shape == "flat" and len(cards) == 1:
card = cards[0]
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
raise DeriveError("one inverter stored flat has no name or "
"capacity", "flat_named", "inverters")
for k in _ROSTER_FLAT:
if card.get(k) is not None or k in keep:
out[k] = card.get(k)
out["inverters"] = []
return out
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
for c in cards]
# The flat keys beside a list are not read: they stay as stored.
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused,
# so a list that replaces the flat inverter clears it.
out["setpoint_entity"] = ""
return out
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
def _strs(v):
return [str(x).strip() for x in (v if isinstance(v, list) else [])
if not _blank(x)]
def _solar_rows(o):
listed = _strs(o.get("pv_entities"))
if listed:
return listed, True
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
return ([one] if one else []), False
def _solar_decode(o):
rows, _listed = _solar_rows(o)
flipped = set(_strs(o.get("pv_invert_entities")))
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
for e in rows]}
def _solar_encode(m, keep):
out = {}
rows = [r for r in (m.get("ui.solar") or [])
if not _blank(r.get("entity"))]
entities = [str(r["entity"]).strip() for r in rows]
old_rows, was_list = _solar_rows(keep)
if len(entities) <= 1 and not was_list:
out["pv_entity"] = entities[0] if entities else ""
if "pv_entities" in keep:
out["pv_entities"] = []
else:
out["pv_entities"] = entities
if "pv_entity" in keep:
out["pv_entity"] = "" # TEL-12: the list replaces it
# Flips of entities that were never a row stay as stored (pv.stray_inverts
# reports them). Flips of removed rows go with the row.
extras = [x for x in _strs(keep.get("pv_invert_entities"))
if x not in old_rows and x not in entities]
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
if flips or "pv_invert_entities" in keep:
out["pv_invert_entities"] = flips
return out
# --- 3. contract and static_profile ----------------------------------------- #
def _fixed_json(price) -> str:
return json.dumps({"periods": [
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
"eur_per_kwh": price}]})
def _daynight_json(day, night) -> str:
return json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": day},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": night},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": night}]})
def _shape(text):
"""The periods of a profile without their names, for comparing, or None."""
try:
prof = parse_static_profile(text)
except ProfileInvalid:
return None
if prof is None:
return ()
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
p.eur_per_kwh) for p in prof.periods))
def _profile_decode(raw):
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
if isinstance(raw, str) and _blank(raw):
return "none", None, None, None
if not isinstance(raw, str):
return "custom", None, None, None
got = _shape(raw)
if not got:
return ("none" if got == () else "custom"), None, None, None
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
return "fixed", got[0][3], None, None
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
if (day is not None and night is not None
and got == _shape(_daynight_json(day, night))):
return "daynight", None, day, night
return "custom", None, None, None
# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- #
# Each row is {"from", "to", "days", "price"} and, when read from a stored
# profile, its "name". `days` is one of these; a profile with any other day set
# is edited in the Configuration tab only (`ui.periods` is None).
PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES
_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else
frozenset(range(5)) if d == "mon-fri" else
frozenset((5, 6)) if d == "sat,sun" else
frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS}
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
def _hhmm(minute) -> str:
return f"{minute // 60:02d}:{minute % 60:02d}"
def _periods_decode(raw):
"""The rows for a stored profile: [] when unset, None when it cannot be
shown as rows (unparsable, or days the editor has no choice for)."""
try:
prof = parse_static_profile(raw) if isinstance(raw, str) else None
except ProfileInvalid:
return None
if prof is None:
return [] if isinstance(raw, str) else None
rows = []
for p in prof.periods:
days = next((d for d, s in _DAYSETS.items() if s == p.days), None)
if days is None:
return None
rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min),
"days": days, "price": p.eur_per_kwh, "name": p.name})
return rows
def _minute(text, *, end):
""""07:00" -> 420, "24:00" -> 1440 as an end, else None."""
m = _TIME.match(str(text or ""))
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if mi > 59 or h > 24 or (h == 24 and (mi or not end)):
return None
return h * 60 + mi
def _periods_json(rows) -> str:
"""The static_profile for the editor's rows, or DeriveError naming the
first problem: a bad time, an empty price, an overlap or a gap."""
if not rows:
raise DeriveError("add at least one time window", "no_periods",
"ui.periods")
periods = []
for i, row in enumerate(rows, start=1):
row = row if isinstance(row, dict) else {}
start = _minute(row.get("from"), end=False)
end = _minute(row.get("to"), end=True)
if start is None or end is None:
raise DeriveError(f"window {i} has a bad time", "period_time",
"ui.periods", row=i)
end = end or MINUTES_PER_DAY # "00:00" as an end
if start == end:
raise DeriveError(f"window {i} is empty", "period_zero",
"ui.periods", row=i)
price = _num(row.get("price"))
if price is None:
raise DeriveError(f"window {i} needs a price", "period_price",
"ui.periods", row=i)
if abs(price) > MAX_PLAUSIBLE_EUR_KWH:
raise DeriveError(f"window {i}'s price is out of range",
"period_price_range", "ui.periods", row=i)
if row.get("days") not in _DAYSETS:
raise DeriveError(f"window {i} has no days", "unknown",
"ui.periods")
name = str(row.get("name") or "").strip() or f"window{i}"
periods.append(Period(name=name, start_min=start, end_min=end,
days=_DAYSETS[row["days"]],
eur_per_kwh=price))
_tiling(periods)
return json.dumps({"periods": [
{"name": p.name, "from": _hhmm(p.start_min),
"to": _hhmm(p.end_min), "days": row["days"],
"eur_per_kwh": p.eur_per_kwh}
for p, row in zip(periods, rows)]})
def _tiling(periods):
"""Each minute of the week in exactly one window, as static_tariff.py
requires. The first overlap or gap, by day and time, is the error."""
for day in range(7):
start, kind, rows = None, None, None
for minute in range(MINUTES_PER_DAY + 1):
here = [i for i, p in enumerate(periods, start=1)
if minute < MINUTES_PER_DAY and p.covers(day, minute)]
now = ("gap" if not here else "overlap" if len(here) > 1
else None) if minute < MINUTES_PER_DAY else "end"
if kind is not None and (now != kind or (kind == "overlap"
and here != rows)):
values = {"day": DAY_NAMES[day], "from": _hhmm(start),
"to": _hhmm(minute)}
if kind == "gap":
raise DeriveError("a time has no window", "period_gap",
"ui.periods", **values)
raise DeriveError("two windows cover one time",
"period_overlap", "ui.periods",
a=rows[0], b=rows[1], **values)
if now in ("gap", "overlap") and kind is None:
start, kind, rows = minute, now, here
def _tariff_decode(o):
raw = o.get("static_profile", "")
kind, fixed, day, night = _profile_decode(raw)
provider = o.get("price_provider")
dynamic = not _blank(provider) and provider != "off"
return {"ui.contract": "dynamic" if dynamic else kind,
"price_provider": provider, "ui.fallback": kind,
"ui.fixed_price": fixed, "ui.day_price": day,
"ui.night_price": night, "ui.periods": _periods_decode(raw)}
def _tariff_encode(m, keep):
out = {}
contract = m.get("ui.contract")
if contract == "dynamic":
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
raise DeriveError("a dynamic contract needs a price source",
"no_source", "price_provider")
out["price_provider"] = m["price_provider"]
kind = m.get("ui.fallback")
else:
out["price_provider"] = "off"
kind = contract
if kind == "none":
out["static_profile"] = ""
elif kind == "fixed":
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
elif kind == "daynight":
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
_price_of(m, "ui.night_price"))
elif kind == "custom":
rows = m.get("ui.periods")
if rows is not None:
out["static_profile"] = _periods_json(rows)
elif "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab", "custom_profile",
"ui.contract")
else:
# Days the editor cannot show: kept as stored.
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}", "unknown",
"ui.contract")
return out
def _price_of(m, name):
value = _num(m.get(name))
if value is None:
raise DeriveError(f"{name} needs a price", "no_price", name)
return value
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
def _region_decode(o):
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
region = OWN
for rid, p in REGION_PRESETS.items():
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
if same and str(profile or "").strip() == p["taxes_profile"]:
region = rid
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
def _region_encode(m, keep):
region = m.get("ui.region")
if region == OWN:
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
if "price_taxes_profile" in keep: # "own" keeps the stored table
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region")
p = REGION_PRESETS[region]
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
if p["taxes_profile"] or "price_taxes_profile" in keep:
out["price_taxes_profile"] = p["taxes_profile"]
return out
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
def arbitrage_from_batteries(m):
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
battery may sell. Battery modes are not built yet, so this returns None and
the toggle is stored as the page has it: no behaviour change."""
return None
def _arbitrage_decode(o):
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
def _arbitrage_encode(m, keep):
derived = arbitrage_from_batteries(m)
value = m.get("arbitrage_enabled") if derived is None else derived
return {} if value is None and "arbitrage_enabled" not in keep \
else {"arbitrage_enabled": value}
# (option keys it writes, decode, encode). Each is independent of the others.
DERIVATIONS = (
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
_solar_encode),
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
_region_encode),
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
)
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
def options_to_ui(options: dict) -> dict:
"""The page model for a stored blob. Pure: reads nothing else."""
model = {k: copy.deepcopy(v) for k, v in options.items()
if k not in _OWNED}
for _keys, decode, _encode in DERIVATIONS:
model.update(decode(options))
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
if k in options}
cards = model.get("inverters")
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
preset = REGION_PRESETS.get(model["ui.region"])
model["region_year"] = preset["year"] if preset else None
return model
def ui_to_options(model: dict) -> dict:
"""The options blob for a page model. Raises DeriveError on a model no blob
can express, so the page says so instead of saving something else."""
out, errors = ui_to_options_partial(model)
if errors:
raise errors[0]
return out
def ui_to_options_partial(model: dict):
"""(blob, [DeriveError]): every derivation that can be made, and the
stored values (`keep`) for each one that cannot. For the checks only, so a
step can be blocked by its own field and the rest still checked (UX-15
F1). Never saved: `ui_to_options()` raises instead."""
keep = model.get("keep") or {}
out = {k: copy.deepcopy(v) for k, v in model.items()
if k not in _MODEL_ONLY and k not in _OWNED
and not k.startswith("ui.")}
errors = []
for keys, decode, encode in DERIVATIONS:
was = decode(keep)
if all(model.get(name) == value for name, value in was.items()):
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
continue
try:
out.update(encode(model, keep))
except DeriveError as err:
err.keys = keys
errors.append(err)
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
return out, errors
+403
View File
@@ -0,0 +1,403 @@
"""UX-13 P2: the settings view's server half. It names no option key.
It turns app/uilayout.py's table into the JSON the page draws from (labels
from the Supervisor's translations, ranges and choices from its schema, page
words from app/ui_text), turns a posted page model into options through
app/uimodel.py, and says beside which page field each validator sentence
goes. The rules stay in validate.py and the sentences are passed on as they
are (DECISIONS/UX-13-one-layout-table.md).
"""
import copy
import os
import re
from . import uilayout as L
from . import uitext
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
# A choice the page cannot make, only keep. None since P5's period editor.
ONLY_IF_HELD = ()
_TIERS = ("bad", "warn", "info")
def ui_text(lang="en") -> dict:
"""The page words in `lang`, English where Dutch has no key (P5)."""
return copy.deepcopy(uitext.words(lang))
def _roster():
"""The inverter list field's key, read off the table."""
return next(f.key for _g, _p, f in L.fields()
if f.control == L.INVERTER_LIST)
def _node(schema_ui, key):
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
nodes, path = schema_ui or [], key.split(".")
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
def _words(translations, lang, key, T):
"""(label, help) for a field: ui_text for `ui.` fields, else the
translation without its `Group · ` prefix, and the raw key last."""
if key.startswith("ui."):
entry = T["fields"].get(key[3:], {})
return entry.get("name", key), entry.get("description", "")
path = tuple(key.split(".")[:-1])
name = key.split(".")[-1]
label, _lang = label_of(translations, lang, name, path)
text, _lang = help_of(translations, lang, name, path)
return split_group(label)[1], text
def _choices(fld, node, T, value_type):
"""[[value, label, only_if_held], ...] for a choice control, or None."""
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
return None
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
words = T["choices"].get(name) or {}
if fld.key.startswith("ui."):
values = list(words)
elif node.get("options"):
values = [v for v in node["options"] if not words or v in words]
elif value_type == "integer" and node.get("lengthMax") is not None:
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
if words:
values = [v for v in values if str(v) in words]
else:
return []
out = []
for v in values:
label = words.get(str(v))
if label is None:
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
if isinstance(v, int) else str(v))
preset = U.REGION_PRESETS.get(v) if fld.fills else None
if preset:
label = f"{label}, " + T["settings"]["region_year"].replace(
"{year}", str(preset["year"]))
out.append([v, label, v in ONLY_IF_HELD])
return out
def _field(fld, schema_ui, translations, lang, T):
node = _node(schema_ui, fld.key)
label, text = _words(translations, lang, fld.key, T)
if fld.unit:
# The page shows the unit beside the control, in its own scale (kW),
# so the translation's "(W)" goes; "(optional)" stays.
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
else node.get("type") or "")
out = {"key": fld.key, "control": fld.control, "label": label,
"help": text, "unit": fld.unit, "scale": fld.scale,
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
if how in (fld.key, fld.mirror))}
if node.get("lengthMin") is not None:
out["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
out["max"] = node["lengthMax"]
choices = _choices(fld, node, T, value_type)
if choices is not None:
out["choices"] = choices
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
later = T.get("choices_later", {}).get(name)
if later:
out["later"] = [[v, words] for v, words in later.items()]
hints = T.get("choice_help", {}).get(name)
if hints:
out["hints"] = {v: e.get("description", "")
for v, e in hints.items()}
if fld.control == L.PERIOD_LIST:
# P5: the day choices of one time window, in the editor's order.
names = T["choices"]["period_days"]
out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS]
if fld.mirror:
out["mirror"] = fld.mirror
if fld.fills:
out["fills"] = fld.fills
out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]}
for rid, p in U.REGION_PRESETS.items()}
roster = _roster()
if fld.key.startswith(roster + "."):
sub = fld.key[len(roster) + 1:]
if L.DERIVED.get(sub) == roster:
out["flat"] = sub # how the one flat inverter stores it
if fld.control == L.INVERTER_LIST:
out["fields"] = [_field(sub, schema_ui, translations, lang, T)
for sub in L.ROSTER_FIELDS]
out["count"] = U.COUNT
out["grow"] = dict(U.GROWN)
out["shape"] = next(iter(U.GROWN))
return out
def layout(schema_ui, translations, lang="en") -> dict:
"""Every group, part and field of the Installer view, as the page draws
it. Built from the table only: a field the table places is on the page."""
T = ui_text(lang)
groups = []
for g in L.GROUPS:
parts = []
for p in g.parts:
words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {}
parts.append({"id": p.id, "name": words.get("name", ""),
"description": words.get("description", ""),
"collapsed": p.collapsed,
"fields": [_field(f, schema_ui, translations, lang, T)
for f in p.fields]})
words = T["groups"].get(g.id, {})
applies = sorted({f.applies for p in g.parts for f in p.fields})
groups.append({"id": g.id, "name": words.get("name", g.id),
"description": words.get("description", ""),
"support_only": g.support_only,
"applies": [T["applies"][a]["name"] for a in applies],
"parts": parts})
return {"groups": groups}
# --------------------------------------------------------------------------- #
# The page model -> options, for the validator and the save
# --------------------------------------------------------------------------- #
def _same(a, b) -> bool:
"""Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not."""
if isinstance(a, bool) or isinstance(b, bool):
return type(a) is type(b) and a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def to_options(model: dict, stored: dict | None = None) -> dict:
"""The options blob for a posted page model. A value equal to the stored
one is written as stored, in the stored key order, so a save that changed
nothing writes an identical blob (the browser reads 3.0 as 3)."""
return _settle(U.ui_to_options(model), stored)
def to_options_partial(model: dict, stored: dict | None = None):
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
settled like `to_options`. Never saved (UX-15 F1)."""
out, errors = U.ui_to_options_partial(model)
return _settle(out, stored), errors
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
"""The blob to save: `stored`, with the keys whose value in `mine` (this
page's model) differs from `base` (the model it opened) set to `mine`'s.
A key this page never edited keeps what is stored now, even when another
surface changed it meanwhile (UX-13 P4 test, F2)."""
edits = {k: v for k, v in mine.items()
if k not in base or not _same(v, base[k])}
gone = {k for k in base if k not in mine}
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
if k not in gone}
out.update({k: copy.deepcopy(v) for k, v in edits.items()
if k not in out})
return out
def _settle(out: dict, stored: dict | None) -> dict:
stored = stored or {}
for k, v in out.items():
if k in stored and _same(v, stored[k]):
out[k] = copy.deepcopy(stored[k])
return ({k: out[k] for k in stored if k in out}
| {k: v for k, v in out.items() if k not in stored})
def derive_message(err: U.DeriveError, lang="en") -> dict:
T = uitext.words(lang)
words = T["settings"]["derive"]
text = words.get(err.code, words["unknown"])["description"]
args = dict(getattr(err, "values", None) or {})
# A time window's day is shown by name (P5 period editor).
if "day" in args:
args["day"] = T["choices"]["period_days"].get(args["day"], args["day"])
return {"field": err.field or None, "card": None, "tier": "bad",
"text": uitext.say(text, **args)}
def derive_messages(errors, problems, notes, model, keys, lang="en"):
"""The page's messages when part of the model could not become options:
each conversion's own sentence beside its field, then the checks of the
rest. A refusal or note about a key the failed conversion writes is left
out, because it judges the stored value, not what the page shows."""
failed = {k for err in errors for k in err.keys}
by_key, unanchored = problems_by_field(problems, keys)
kept = [t for k, texts in by_key.items() if k not in failed
for t in texts] + list(unanchored)
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
# --------------------------------------------------------------------------- #
# Where a sentence goes on the page
# --------------------------------------------------------------------------- #
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
_SUB = re.compile(r"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
def _mirror_of(key):
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
def targets(key: str, text: str, model: dict) -> list:
"""[(page field, card)] for a sentence that names option `key`. `card` is
the inverter card's index, or None."""
roster = _roster()
cards = model.get(roster) if isinstance(model.get(roster), list) else []
names = [c.get("name") for c in cards if isinstance(c, dict)]
# messages.setting(): “Inverter rating” for inverter-b (inverters.max_w),
# or a roster rule's own `inverters[1] (inverter-b)`.
card = next((i for i, n in enumerate(names)
if n and f"for {n} ({roster}." in text), None)
index = re.search(re.escape(roster) + r"\[(\d+)\]", text)
if card is None and index and int(index.group(1)) < len(cards):
card = int(index.group(1))
if key == roster:
subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card)
for r, i, s in _SUB.findall(text) if r == roster]
subs = [x for x in dict.fromkeys(subs) if L.field(x[0])]
return subs or [(roster, card)]
if key in L.DERIVED:
how = L.DERIVED[key]
if how == roster:
return [(f"{roster}.{key}", 0 if card is None else card)]
fld = L.field(how)
if fld is not None and not L.visible(fld, model) and _mirror_of(how):
return [(_mirror_of(how), None)]
return [(how, None)]
if L.field(key) is not None:
return [(key, None)]
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
out, seen = [], set()
def add(field, card, tier, text):
if (field, card, text) not in seen:
seen.add((field, card, text))
out.append({"field": field, "card": card, "tier": tier,
"text": text})
by_key, unanchored = problems_by_field(problems, keys)
for key, texts in by_key.items():
for text in texts:
for field, card in targets(key, text, model):
add(field, card, "bad", text)
for text in unanchored:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
if (m["field"] or "").startswith(roster + ".")}
return [m for m in out if not (m["field"] == roster
and m["text"] in placed)]
def fields_of(keys, model) -> list:
"""The page fields that show these option keys (a stale save's list)."""
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
if f))
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
# --------------------------------------------------------------------------- #
# Store keys (app/store.py), not options. SETUP_DONE is written when the
# wizard saves. STARTED is the one control's Start and Stop write.
SETUP_DONE = "setup_done"
STARTED = "auto"
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
"""Why the page opens the wizard by itself, or None.
"no_power_request": the stored options name no inverter to control, so
the add-on cannot start on them. "first_start": a new install. This boot
found no Store file (`fresh`), the wizard never saved and control was never
started or stopped. `fresh` is what tells a new install from an old site
that was never started, e.g. one left in shadow for weeks (TEST-23).
`store_data` is the Store's dict (None: unknown, so no), `started` is
whether control is started now."""
if not power_request_entities(stored or {}):
return "no_power_request"
if store_data is None or not fresh:
return None
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
return None
return "first_start"
def setup_spec() -> dict:
"""What the wizard needs beyond each field's step: read off the table."""
kettle = {}
for source, (read, outcomes) in L.KETTLE.items():
kettle[source] = {"read": [[f, sign] for f, sign in read]}
for way, (say, sets) in outcomes.items():
kettle[source][way] = {"say": say, "sets": sets}
field, on, off = L.SHADOW
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
"optional": list(L.OPTIONAL_STEPS),
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
"sources": kettle},
"shadow": {"field": field, "on": on, "off": off}}
def kettle(source, before_w, after_w):
"""The kettle test's verdict, as the page reaches it (settings.js
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
for a source the page cannot read."""
spec = L.KETTLE.get(source)
if spec is None:
return None
change = float(after_w) - float(before_w)
if abs(change) < L.KETTLE_MIN_W:
return "none", None, change
say, sets = spec[1]["up" if change > 0 else "down"]
return say, (dict(sets) if sets is not None else None), change
def step_of(field_key, model) -> int:
"""The wizard step a sentence on this page field blocks: the field's own
step while it is shown, else the last step, which lists everything."""
fld = L.field(field_key) if field_key else None
if fld is None or not fld.wizard or not L.visible(fld, model):
return L.FINAL_STEP
return fld.wizard
def step_problems(messages, model) -> dict:
"""{"1": n, ...}: how many refusals block each wizard step."""
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
for m in messages:
if m.get("tier") == "bad":
out[str(step_of(m.get("field"), model))] += 1
return out
+51
View File
@@ -0,0 +1,51 @@
"""UX-13 P5: the page's words, app/ui_text/<lang>.json, in English or Dutch.
One file per language with the same keys. A key missing from a language falls
back to English, key by key, the way the Supervisor's translations do.
test_ui_text.py holds both files to STYLE.md and to each other.
"""
import copy
import io
import json
import os
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
LANGS = ("en", "nl")
_CACHE: dict = {}
def lang_of(value) -> str:
""""nl", "nl-BE" or "nl_NL" -> "nl"; anything else -> "en"."""
head = str(value or "").strip().lower().replace("_", "-").split("-")[0]
return head if head in LANGS else "en"
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback."""
if lang not in _CACHE:
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
_CACHE[lang] = json.load(f)
return _CACHE[lang]
def _merge(base, over):
if not (isinstance(base, dict) and isinstance(over, dict)):
return copy.deepcopy(over)
out = copy.deepcopy(base)
for k, v in over.items():
out[k] = _merge(base.get(k), v) if k in base else copy.deepcopy(v)
return out
def words(lang="en") -> dict:
"""Every word in `lang`, English where a key is missing."""
lang = lang_of(lang)
return raw("en") if lang == "en" else _merge(raw("en"), raw(lang))
def say(template: str, **values) -> str:
"""Fill `{name}` placeholders, as settings.js `say()` does."""
for k, v in values.items():
template = template.replace("{" + k + "}", str(v))
return template
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -670,11 +670,10 @@ class SignWatchdog:
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
# and it defaults to the wording that shipped - "a meter reading" - so a
# one-inverter install's `reason` is byte-identical to what an operator
# (and test_watchdog.py) has always read. A per-unit instance names what
# it actually compared instead, because "commanded +1500 W against a
# meter reading -1500 W" would be a false statement about which
# instrument produced the evidence, on the one line somebody woken by
# this alarm reads first.
# has always read (test_watchdog.py pins its meaning). A per-unit
# instance names what it actually compared instead, because "commanded
# +1500 W against a meter reading -1500 W" would be a false statement
# about which instrument produced the evidence.
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
+249 -101
View File
@@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
`/` is the status and control surface and is unchanged. `/config` is the
`/` is the status and control surface; since UX-13 P1 its files are in
app/static/ and its Home screen reads the structured half of `/status`
(app/homeview.py). `/config` is the
configuration surface: its markup lives in app/config_page.py, its field model
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
stays HERE is the routing, so that the question a reviewer has to answer - does
@@ -24,97 +26,70 @@ routes below read `GET /addons/self/info`, run the SHIPPED validator, write
mode or reaches `controller.handle_action()`. The set of things an operator can
do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set.
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
of Home Assistant's entity list (app/haentities.py) and writes nothing.
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
not options is a Store key, `setup_done`, written when the wizard's save
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
commands nothing; there `/action` answers 409.
OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one
battery's on/off switch or mode in the store (Controller._pack_action); P3 adds
`pack_sell`, that battery's selling switch. They
are the only actions that name a battery; the configuration routes still reach
none of them.
"""
import io
import json
import logging
import os
from datetime import datetime, timezone
from aiohttp import web
from . import selfconfig
from . import messages, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe RS485 Controller</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
*{box-sizing:border-box}
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
h1{font-size:18px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
tr:last-child td{border-bottom:0}
.k{color:var(--muted);font-weight:400}
ul{margin:6px 0 0;padding-left:18px}
li{margin:3px 0}
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
# OPT-11: the per-battery actions, which name a battery and a value.
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
<div class="card">
<table id="live"></table>
</div>
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
# and, since P5, Dutch; the page picks by the browser's language). They are
# joined into ONE response here, once, so the page needs no second request
# and no route of its own: the route list below is unchanged, and every URL
# the page uses stays relative (ingress). No build step, no external file.
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
<div class="card">
<div class="k">Commissioning</div>
<ul id="checks"></ul>
</div>
<div class="card">
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
<button onclick="act('maint_start')">Force maintenance cycle</button>
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
<div class="muted" id="hint"></div>
</div>
def _read(path):
with io.open(path, encoding="utf-8") as f:
return f.read()
<script>
async function refresh(){
try{
const r = await fetch('status', {cache:'no-store'});
const s = await r.json();
const b = document.getElementById('banner');
b.textContent = s.banner;
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
document.getElementById('live').innerHTML = s.rows
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
document.getElementById('checks').innerHTML = s.checks
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
.join('');
document.getElementById('hint').textContent = s.hint || '';
}catch(e){
document.getElementById('banner').textContent = 'cannot reach the add-on';
}
}
async function act(what){
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: what})});
refresh();
}
refresh(); setInterval(refresh, 3000);
</script></body></html>
"""
def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps({lang: uitext.raw(lang) for lang in uitext.LANGS},
ensure_ascii=False)
scripts = {}
for name in ("app.js", "settings.js"):
scripts[name] = _read(os.path.join(STATIC, name))
if "</script" in scripts[name].lower():
raise ValueError(f"{name} may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", scripts["app.js"])
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -125,11 +100,8 @@ refresh(); setInterval(refresh, 3000);
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -143,12 +115,34 @@ def build_app(controller, selfcfg=None) -> web.Application:
if selfcfg is None:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
# save can name only the keys that moved underneath the page (finding a).
handed: dict = {}
def hand_out(options):
fp = selfconfig.fingerprint(options)
handed.pop(fp, None)
handed[fp] = options
while len(handed) > 16:
handed.pop(next(iter(handed)))
return fp
def store_data():
store = getattr(controller, "store", None)
data = getattr(store, "data", None)
return data if isinstance(data, dict) else None
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
return web.Response(text=index_page, content_type="text/html")
async def status(_request):
return web.json_response(controller.status_payload())
async def status(request):
# UX-13 P5: Home's sentences in the page's language, English or Dutch.
lang = uitext.lang_of(request.query.get("lang"))
if lang == "en":
return web.json_response(controller.status_payload())
return web.json_response(controller.status_payload(lang=lang))
async def action(request):
try:
@@ -156,7 +150,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
what = body.get("action")
result = await controller.handle_action(what)
if getattr(controller, "setup_only", False):
# UX-15: nothing to start or stop until setup is saved.
return web.json_response(
{"ok": False, "error": messages.row_setup_only()}, status=409)
if what in PACK_ACTIONS:
# OPT-11: one battery's switch or mode. Store state for the named
# battery, applied on the next cycle; nothing is written here.
result = await controller.handle_action(
what, unit=body.get("unit"), value=body.get("value"))
if isinstance(result, dict) and "error" in result:
return web.json_response({"ok": False,
"error": result["error"]},
status=400)
else:
result = await controller.handle_action(what)
if result is None:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
return web.json_response({"ok": True, "result": result})
@@ -165,15 +173,31 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_index(_request):
return web.Response(text=CONFIG_PAGE, content_type="text/html")
async def _proposed(request):
async def _proposed(request, stored=None):
"""(body, options, model). UX-13's settings view posts its page
`model`, which app/uimodel.py turns into the options blob here, so the
validator and the Supervisor only ever see a real blob. The classic
page posts `options`. Raises DeriveError for a model no blob can say.
With `stored` (a save) and the `base` model the page opened, only the
keys this page changed are written onto what is stored now, so a save
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
mine = uipage.to_options(model, stored)
base = body.get("base")
if stored is not None and isinstance(base, dict):
was, _errors = uipage.to_options_partial(base, stored)
mine = uipage.only_edits(mine, was, stored)
return body, mine, model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
return body, options
return body, options, None
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -198,10 +222,25 @@ def build_app(controller, selfcfg=None) -> web.Application:
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"fingerprint": hand_out(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
# screens, which show the year beside the region choice.
"region": region_of(options),
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
options, store_data(), bool(getattr(controller, "auto", False)),
fresh=getattr(getattr(controller, "store", None), "fresh",
False) is True),
# UX-15: this process serves setup only and commands nothing.
"setup_only": getattr(controller, "setup_only", False) is True,
})
return web.json_response(model)
@@ -218,16 +257,60 @@ def build_app(controller, selfcfg=None) -> web.Application:
product this card is delivering; anything this layer did to them would
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
try:
_body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = _body.get("model")
errors = []
if isinstance(model, dict):
# UX-15 F1: a part the page cannot turn into options blocks its
# own step, and everything else is still checked.
options, errors = uipage.to_options_partial(model)
else:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass)
options, controller.hass, notes=notes)
by_key, unanchored = problems_by_field(problems, _keys_of(options))
# UX-13: warnings and info beside the refusals. Never block a save.
# The entity checks use the picker's cached list and never wait on HA.
notes += config_notes(options, entities=entities.peek())
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored}
"unanchored": unanchored,
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options),
uitext.lang_of(_body.get("lang")))
if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
return web.json_response(payload)
async def _setup_checks(options):
"""The wizard's live checks: a list at most a few seconds old, and
what the running add-on already knows (its meter, today's prices)."""
rows, age = await entities.get(max_age=5.0)
if age is not None and age >= 30.0:
rows = None # Home Assistant is not answering
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
meter_now_w=getattr(controller, "grid", None),
prices_known=known)
def _keys_of(options):
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
# sound only because the page posts the WHOLE blob it read - which is
@@ -238,33 +321,78 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_save(request):
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
body, options = await _proposed(request)
# The page model carries no stored values of the keys it shows, so the
# blob is settled against what is stored: an unchanged value is written
# exactly as stored (uipage.to_options).
try:
posted = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError:
model = posted["model"]
_opts, errors = uipage.to_options_partial(model, stored_now)
lang = uitext.lang_of(posted.get("lang"))
messages_ = [uipage.derive_message(e, lang) for e in errors]
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
hass=controller.hass,
base=handed.get(expect))
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
# The fields to look at are the ones the OTHER surface moved,
# not this page's own edits (UX-13 P2 test, finding a).
moved = (refused.changed if refused.moved is None
else refused.moved)
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
# made in front of what changed.
"fingerprint": selfconfig.fingerprint(stored)},
"fingerprint": hand_out(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
return web.json_response(
{"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}, status=422)
payload = {"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}
if model is not None:
payload["messages"] = uipage.anchors(refused.problems, [],
model, _keys_of(options))
payload["steps"] = uipage.step_problems(payload["messages"],
model)
return web.json_response(payload, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
# `handed` keeps `expect`: another page may have opened the same blob.
store = getattr(controller, "store", None)
if (model is not None and body.get("setup") == "done"
and store is not None):
store.set(uipage.SETUP_DONE,
datetime.now(timezone.utc).isoformat(timespec="seconds"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -288,9 +416,20 @@ def build_app(controller, selfcfg=None) -> web.Application:
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
controller.log_event("restart requested from the configuration panel",
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True})
async def ha_entities(request):
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
one = str(request.query.get("entity", "") or "")[:255]
if one:
# UX-13 P4's kettle test: one entity, read now.
return web.json_response(await entities.reading(one))
return web.json_response(await entities.payload(kind))
app = web.Application()
app.add_routes([
web.get("/", index),
@@ -301,12 +440,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
web.post("/config/validate", config_validate),
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
web.get("/ha/entities", ha_entities),
])
return app
def access_log():
"""UX-10: the page polls /status every few seconds, and aiohttp logs each
request at INFO. That access log is kept for a DEBUG log level only."""
return (logging.getLogger("aiohttp.access")
if _LOG.isEnabledFor(logging.DEBUG) else None)
async def start(controller, port: int = 8099, selfcfg=None):
runner = web.AppRunner(build_app(controller, selfcfg))
runner = web.AppRunner(build_app(controller, selfcfg),
access_log=access_log())
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
+131 -6
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.10.0"
version: "0.12.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -93,7 +93,7 @@ options:
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
# validate.py rule 9 enforces it again for the callers the Supervisor never
# validate.py rules 8 and 9 enforce it again for the callers the Supervisor never
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
# SENSOR - `inputs_ok` already stops the site for that - and never means
# "use the other one's".
@@ -146,6 +146,88 @@ options:
# options blob, and the upgrade leaves an add-on that cannot start.
inverters: []
estop_fitted: false
# OPT-11: battery modes. Off by default, so an install that has not turned
# them on behaves exactly as before. When on, each battery gets an on/off
# switch and a mode on the Batteries screen, kept in the add-on's store.
# DECISIONS/OPT-11-battery-modes.md.
battery_modes_enabled: false
# OPT-11 P4: a Home Assistant solar forecast (kWh left today; tomorrow's is
# optional) and how much of it to count on. Read only with battery modes
# on; empty means no day plan (P3 option A). app/solarfc.py lists the
# integrations whose sensors fit.
pv_forecast_entity: ""
pv_forecast_tomorrow_entity: ""
solar_trust_pct: 70
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
# when it is empty the add-on reads no entity, announces no entity, publishes
# no topic and shows no status row.
#
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
# drives; a household's PV inverter is normally a different box from a
# different vendor, reporting into Home Assistant through its own integration.
# So this takes whatever `sensor.*` the site's PV integration provides, any
# brand Home Assistant can read.
#
# POSITIVE IS PRODUCTION. An entity that reads the other way round goes in
# `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md.
#
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
# and the rest of Home Assistant's power units. A sensor with no unit, or with
# a unit that is not power, is REFUSED and the status page says which: there
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
# a factor of 1000 in the one channel that exists to be believed a week later
# is worse than a refusal an operator can read.
pv_entity: ""
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
# routinely carries two or three PV inverters, each publishing its own power
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
# site's owner reported within minutes that they could configure one of
# theirs. List them all here and the row carries the SITE total.
#
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
# ONLY this list is read, and the status page says so. That is CFG-04's rule
# for `inverters:` applied to the upgrade path this key exists for: the
# install being upgraded HAS `pv_entity` set, and an operator who adds all
# their inverters to this list while the old key still names one of them would
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
# this in.
#
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
# sum of the ones that answered: two of three inverters add up to a smaller
# number that is indistinguishable from a real one, and a week later that
# reads as "there was less sun than there was" - the exact conclusion this
# channel exists to TEST rather than to manufacture. The status page names
# which entity is missing and why, and lists what the others reported, because
# a human reading it live wants the diagnostic; the recorded number does not,
# because nobody re-derives a week of history. See
# DECISIONS/TEL-12-several-pv-inverters.md.
#
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
# prefer one, but a template sensor summing an `unavailable` source yields the
# partial total silently - the same defect one layer down, in a place this
# add-on cannot see or report.
#
# pv_entities:
# - sensor.pv_east_power
# - sensor.pv_west_power
# - sensor.pv_garage_power
#
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
# which is also empty" - i.e. no PV channel at all, which is every install.
pv_entities: []
# TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has
# its sign flipped, for a kWh meter or power clamp that reads production as
# negative. Per entity, because one roster can mix both conventions. `[]` is
# load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities: []
# ==========================================================================
# METER - where the grid reading comes from
@@ -156,7 +238,7 @@ options:
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# local API. ⚠️ It is the ONLY mode whose sensor.goodwe_p1_sample_age measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
@@ -215,6 +297,8 @@ options:
# protects the shallower pack with the deeper pack's number.
bms_soc_floor: 10
peak_cap_w: 3500
# SAFETY-79: the peak guard aims this many watts under peak_cap_w.
peak_margin_w: 50
peak_forecast_entity: ""
# The peak guard can COMMAND a discharge to hold a billed quarter under
@@ -848,12 +932,13 @@ schema:
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
# no `max_w` would have to borrow a rating, which is the sibling-grading
# defect DEV-02 removed. The optional pair is the sensors, whose absence
# honestly means "this unit reports none".
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
# The optional keys are the sensors, whose absence honestly means "this unit
# reports none", and `capacity_kwh`.
#
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
# TWICE under `schema:` - once nested here, once flat below - and a reader
@@ -872,6 +957,45 @@ schema:
capacity_kwh: float(0.1,200)?
estop_fitted: bool
battery_modes_enabled: bool
pv_forecast_entity: str?
pv_forecast_tomorrow_entity: str?
solar_trust_pct: int(30,100)
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
# Nothing in validate.py mentions this key: it cannot conflict with anything,
# because nothing else reads it.
pv_entity: str?
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
# and the reference site has a string persisted in it. Declared as a list,
# Supervisor 2026.08.0 answers that stored string with
#
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
#
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
# §B's trap in the other direction: an add-on that will not start, and the
# schema needed to fix it pinned to the version that cannot be installed. The
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
# which validates nothing at all (it admits a number, a dict, any list of
# anything) and renders as a plain text box. Measured, both of them, by
# `tools/supervisor_shape_probe.py` §G - the transcript is in
# DECISIONS/TEL-12-several-pv-inverters.md §1.
#
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
# `inverters: []` is. §G measures the same isolated counterfactual: the
# shipped defaults MINUS only this key are refused with `Missing option
# 'pv_entities' in root`, which is every options blob written before 0.12.
# `p1_phase_import_entities` is the shipped precedent for the pair.
pv_entities:
- str
# TEL-14. A parallel list, not a prefix or a dict: measured by
# tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md.
pv_invert_entities:
- str
# ==========================================================================
# METER - where the grid reading comes from
@@ -907,6 +1031,7 @@ schema:
max_w: int(100,5000)
bms_soc_floor: int(0,50)
peak_cap_w: int(500,15000)
peak_margin_w: int(0,500)
peak_forecast_entity: str?
# SAFETY-24. No "off" value: a guard that can command a discharge does not get

Some files were not shown because too many files have changed in this diff Show More