Compare commits

..
308 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 ae0ef4296b UX-46 review: Dutch wording fixes on the NL rows and notes
checks / unit (pull_request) Successful in 45m44s
infra-pin / fresh (pull_request) Successful in 1s
Text only. "stroom te opnemen" -> "stroom op te nemen"; Developer Tools is
"Ontwikkelhulpmiddelen" in HA and ui_text; the green stuck-meter row no longer
starts "Vastgelopen meter:"; maintenance banner names the phase in Dutch;
double "dan" in the device note; plainer peak reserve, peak guard, refused
commands and missing-entity wording. Two stale comments now say the banner
and Home bodies follow the page's language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:56:30 +02:00
glenn schrooyenandClaude c1809b4ca9 UX-46: test_ux46 points at MUTATION.md and reads rows without raising
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:41:18 +02:00
glenn schrooyenandClaude 7a392a5367 UX-46: Dutch status rows, banners, notes and wizard lines on the NL pages
Rows and banners are built once with their Dutch beside them (messages.Bi)
and status_payload() picks one by the page's language. Settings notes and the
wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by
the posted language. The peak forecast label drops "(W)" in EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:21:41 +02:00
admin e077ae7f97 Merge pull request 'SAFETY-121: a recurring overload warns every quarter' (#204) from SAFETY-121 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-21 12:41:12 +02:00
glenn schrooyenandClaude Opus 5.5 24f31a9c3a SAFETY-121 review: DECISIONS limits state the jitter spread and the slow-cycle case
checks / unit (pull_request) Successful in 44m29s
infra-pin / fresh (pull_request) Successful in 1s
The jittery-meter numbers depend on the draw (review: 4, 4, 5 of 11 at the
quarter edge; up to 4 quiet quarters at 20 s; first warning up to 61 min
later). Above about 40 s per cycle, a short bound can still use up the
budget when the wire changes hands every few cycles. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:55:24 +02:00
glenn schrooyenandClaude 8bf25696cb SAFETY-121: a recurring overload warns every quarter
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 1s
TEST-23 F-1: on a 5 s meter a steady overload warned once in 70 min. The
loop was at its bound 4-5 cycles a quarter, all inside SAFETY-119's settle
of 12 cycles (60 s) and under its budget of 8 per 900 s. The simulation
fed the meter a cycle behind the battery reading, so the guard and the
charge chased each other and gave ~20 bound cycles a quarter.

The settle is now 4 cycles and 22 s (HANDBACK_CYCLES, SHORT_SPAN_S), and
the rolling budget one number, 11 per 900 s, at every cycle length. The
cycle length is no longer tracked. Commands unchanged; the re-pinned
digests of test_safety84/87 differ from bee7431 in OUTPUT_CLAMP rows only.
test_safety119 runs the rig's shape (meter in the same cycle) at 1-15 s
and checks a stop keeps the budget (review F2, X3).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 10:09:14 +02:00
admin bee7431793 Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
2026-10-21 03:32:38 +02:00
glenn schrooyenandClaude Opus 5.5 96375ecbec SAFETY-119 review: DECISIONS states the steady-meter limit
checks / unit (pull_request) Successful in 43m33s
infra-pin / fresh (pull_request) Successful in 2s
The quarter-edge numbers were measured with one cycle per reading at a
steady interval. With the default 15 s cycle floor, a meter that is
sometimes slower adds cycles on an old reading and the false alarm can
come back (reviewer's runs, 11 seeds). Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:47:27 +02:00
glenn schrooyenandClaude 6fd0fa05f1 SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s
No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 00:57:43 +02:00
glenn schrooyenandClaude e27e62ff72 SAFETY-119 round 3b: no-clock budget, fixed-seed tests, accepted limits
checks / unit (pull_request) Successful in 43m1s
infra-pin / fresh (pull_request) Successful in 1s
The rolling budget now holds without a monotonic clock (it never refills).
Quarter-edge tests use seeds 1-3 at 2, 5, 10 and 15 s (no hand-picked seeds).
Re-pin comments point at part 6. DECISIONS lists the limits the owner
accepted with N=8 on 2026-10-20.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 20:46:14 +02:00
glenn schrooyenandClaude 86b94bedb5 SAFETY-119 round 3: DECISIONS table ranges over all runs
checks / unit (pull_request) Successful in 42m30s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:28:22 +02:00
glenn schrooyenandClaude 103db2fcda SAFETY-119 round 3: rolling 15-minute budget for the hidden integrator bound
checks / unit (pull_request) Successful in 42m33s
infra-pin / fresh (pull_request) Successful in 2s
At most 8 hidden bound cycles in any 900 s across all hand-backs, replacing
the per-run budget. Chosen from a sweep (N 3 to 10, 1 to 15 s meters, both
cases); DECISIONS has the table. Tests: never case at 5, 10, 15 s, quarter
edge at 2, 10, 15 s, the rolling budget at its boundaries.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:27:43 +02:00
glenn schrooyenandClaude 85542e443b SAFETY-119 round 2: test_safety119 reads through named_absence
checks / unit (pull_request) Successful in 42m22s
infra-pin / fresh (pull_request) Successful in 1s
test_named_absence flagged two unguarded reads (next() without a default,
a list indexed in a check name). Use first(), nth() and present().

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 01:38:24 +02:00
glenn schrooyenandClaude e76c70ecb0 SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s
A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 00:52:24 +02:00
glenn schrooyenandClaude Opus 5.5 b9ff6acea4 SAFETY-119 review: DECISIONS, at least once a quarter
checks / unit (pull_request) Successful in 42m1s
infra-pin / fresh (pull_request) Successful in 1s
A bigger house warns more often (every 150 s at 7.4 kW), so 'about' was
wrong. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:29:00 +02:00
glenn schrooyenandClaude Opus 5.5 98026f9a46 SAFETY-119 review: DECISIONS states the worst delay and the no-warn case
checks / unit (pull_request) Successful in 42m3s
infra-pin / fresh (pull_request) Successful in 1s
The delay is 11 cycles, so up to 11 min at a 60 s cycle. A loop that loses
the wire again within 11 cycles is never counted; review measured one such
case (N=2, cheap-window charge, no forecast, house just over the rating).
The over-rating rule applies on cycles the loop decided. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:17:08 +02:00
glenn schrooyenandClaude b0c2e5977d SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 15:30:18 +02:00
glenn schrooyenandClaude c4e15ec4cd SAFETY-119: no "not keeping up" when the loop takes the wire back
When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.

The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 14:14:08 +02:00
admin 75f0804f9d Merge pull request 'UX-45: Home price chart shows the 15-minute prices the add-on uses' (#202) from UX-45 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-19 12:15:56 +02:00
glenn schrooyenandClaude eb8375157d UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 10:01:58 +02:00
glenn schrooyenandClaude 17c6048417 UX-45: Home price chart draws one bar per price step the add-on uses
checks / unit (pull_request) Successful in 40m15s
infra-pin / fresh (pull_request) Successful in 1s
With 15-minute prices the chart now has one bar per quarter (92/96/100),
not hour averages; hourly and static tariffs still draw one bar per hour.
Cheap and dear marks come from the kept priceruns result (battery modes
on) or the arbitrage verdict's two points (off), not a thirds split.
A missing quarter is an empty bar. Removes plan.average_tip (EN/NL).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:35:29 +02:00
admin 243a756e7b Merge pull request 'DOC-8: say EMS port, not meter bus' (#201) from DOC-8 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-17 04:09:33 +02:00
glenn schrooyenandClaude Opus 5.5 a4a7ff695e DOC-8 review: say the field guide PDF is rebuilt, still v0.1.0
checks / unit (pull_request) Successful in 40m29s
infra-pin / fresh (pull_request) Successful in 1s
README and FIELD-GUIDE.md said the PDF was not rebuilt; it was, on
2026-10-17. Its content is still v0.1.0 and outdated. Rewrap the
DOCS.md intro line. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:27:35 +02:00
glenn schrooyenandClaude b8b357b552 DOC-8: update test_mqtt digest pins for the new manufacturer string
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 40m23s
Both digests hash the discovery payloads, which carry the manufacturer.
Values regenerated; nothing else changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 02:16:34 +02:00
glenn schrooyenandClaude 4584502adb DOC-8: HA manufacturer says EMS port; rebuild field guide PDF
checks / unit (pull_request) Failing after 41m1s
infra-pin / fresh (pull_request) Successful in 1s
Device id (identifiers) is unchanged; only the display text moves.
test_mqtt baseline updated for that one string.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:53:33 +02:00
glenn schrooyenandClaude fd08090360 DOC-8: say EMS port, not meter bus
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
The add-on is wired to the inverter's RS485 port labelled EMS. Reword
README, DOCS, config description, field guide source and HTML. The HA
manufacturer string is left unchanged: test_mqtt pins it byte for byte.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:09:42 +02:00
admin c67d0cdb67 Merge pull request 'Release 0.18.2' (#199) from release-0.18.2 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-16 22:31:09 +02:00
glenn schrooyenandClaude Opus 5.5 ea77c63552 CHANGELOG 0.18.2: say how a no-unit forecast shows; who needs a look
checks / unit (pull_request) Successful in 40m19s
infra-pin / fresh (pull_request) Successful in 1s
Review of PR #199. "Warns once" left out the Home row and the Settings
note, which stay while the fault lasts. "No option change" now points to
the one case that needs a look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:48:05 +02:00
glenn schrooyenandClaude 75f4359aac Release 0.18.2
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 2s
Version bump to 0.18.2 and a CHANGELOG section for SAFETY-118 (#198).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 20:54:29 +02:00
admin 09a54859f2 Merge pull request 'SAFETY-118: read the peak forecast in its own unit' (#198) from SAFETY-118 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-16 19:53:54 +02:00
glenn schrooyenandClaude 7d52dc1c0f SAFETY-118: a missing peak forecast reading keeps the unit-fault state
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 40m19s
A missing reading (unavailable, unknown, failed read, non-numeric) no longer
clears the fault: no false 'used again' INFO, no repeated WARNING, the status
row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 18:19:44 +02:00
glenn schrooyenandClaude f0b4e68e49 SAFETY-118: read the peak forecast in its own unit
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
peak_forecast_entity is read through pv.watts() by its unit_of_measurement;
the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the
forecast is not used, with one warning per cause. The validator no longer
claims a kW sensor is taken as watts for this entity.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 16:00:34 +02:00
admin 780c1f67bb Merge pull request 'Release 0.18.1' (#196) from release-0.18.1 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m21s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.18.1
2026-10-16 13:46:24 +02:00
glenn schrooyenandClaude Opus 5.5 0ee3c3468b Release 0.18.1 review: CHANGELOG text fixes
checks / unit (pull_request) Successful in 40m18s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-118 line: the add-on already turns a kW forecast into W
(main.py read_inputs, abs(raw) < 50), so only the settings-page warning is
wrong. Rounding note and rows: the new settings page; UX-44 also changed
one DOCS line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:03:37 +02:00
glenn schrooyenandClaude dec8e7b69f Release 0.18.1
checks / unit (pull_request) Successful in 40m39s
infra-pin / fresh (pull_request) Successful in 2s
Version 0.18.0 -> 0.18.1 (patch, no option changes) and a closed 0.18.1 CHANGELOG section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 12:08:58 +02:00
admin 8e02a0928b Merge pull request 'UX-44: contract label says every 15 minutes, not hourly' (#195) from UX-44 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-16 06:59:19 +02:00
glenn schrooyenandClaude 1bd2ba1397 UX-44: contract label says every 15 minutes, not hourly
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00
admin b703aaa6d5 UX-43: tariff prices take five decimals (#194)
infra-pin / fresh (push) Successful in 2s
2026-10-16 04:30:09 +02:00
UX-43 review 92a3d3400a UX-43 review: plain wording for the DOCS line on five-decimal prices
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:47:28 +02:00
glenn schrooyenandClaude 8c954acb36 UX-43: keep each field description to one sentence (STYLE)
checks / unit (pull_request) Successful in 40m31s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 02:29:31 +02:00
glenn schrooyenandClaude 8f72ab00d7 UX-43: tariff price fields take five decimals
ui.fixed_price, ui.day_price, ui.night_price and ui.periods step 0.00001
EUR/kWh (the period editor's price input took a hard-coded 0.01). A short
c-EUR/kWh hint is added to the four field descriptions, EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 01:40:40 +02:00
admin a0e091cca3 Release 0.18.0 (#192)
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-15 23:17:10 +02:00
glenn schrooyenandClaude Opus 5.5 44cd1fde2a Release 0.18.0: review fixes to the CHANGELOG section
checks / unit (pull_request) Successful in 40m17s
infra-pin / fresh (pull_request) Successful in 2s
OPT-15 and OPT-20 rows: the retry is two inverters only, and OPT-20 no
longer claims no other battery moves. SAFETY-111 added to known issues.
MAINT-19 marked as from code reading. Wording fixes in the SAFETY-112 and
MAINT-11 rows, UX-38 and the price service note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:35:47 +02:00
Claude e7c83d3a57 Release 0.18.0: version bump and CHANGELOG section
checks / unit (pull_request) Successful in 40m26s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.17.0 -> 0.18.0 and a closed ## 0.18.0 CHANGELOG
section for OPT-17, SAFETY-112, OPT-15, MAINT-11, PRICE-01 and OPT-20.
No code change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 21:37:17 +02:00
adminandClaude Opus 5.5 004f859ec7 Merge PR #191: OPT-20 a refusal probe asks only PROBE_W, not the whole trade
infra-pin / fresh (push) Successful in 1s
Reviewed at ccdb9dc: full suite 78/78, CI run 1289 green, identity equal to bf9019e. Review: PR #191 comment 2185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:56:17 +02:00
glenn schrooyenandClaude ccdb9dc17a OPT-20: test_opt20 points at MUTATION.md (test_bytecode part 5)
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:12:22 +02:00
glenn schrooyenandClaude 06339a2446 OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s
The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.

modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:08:59 +02:00
admin bf9019e70d Merge PR #190: PRICE-01 price_service provider, fallback chain and gate
infra-pin / fresh (push) Successful in 1s
2026-10-14 22:47:43 +02:00
adminandClaude Opus 5.5 6862dea52a PRICE-01 review 3: tests for the round 3 note and dismiss; doc wording
checks / unit (pull_request) Successful in 39m4s
infra-pin / fresh (pull_request) Successful in 2s
- test_price_secret.py: the fixed-tariff note on the real /config/validate
  route, EN and NL, and none for a spaces-only profile (web.py lang wiring).
- test_price_service.py: the start line is INFO, not WARNING; a dismiss that
  raises does not stop the cycle.
- test_maint13/test_setup_only/test_watchdog: ignore only the dismiss of the
  old card, so a create with that id is still counted.
- DECISIONS rule 7 quotes the settings note as it is; messages.py comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 22:07:09 +02:00
glenn schrooyenandClaude c68fd275cc PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
checks / unit (pull_request) Successful in 39m6s
infra-pin / fresh (pull_request) Successful in 1s
G1: remove the false 'Fixed tariff not used' notification. One INFO line at
start and an info note beside the setting (EN/NL) when price_service has a
non-blank static_profile; the old notification id is dismissed at start.
DOCS, migration step 5 and DECISIONS rule 7 now say what the code does.
S05: whitespace-only static_profile counts as not set.
G2: maintenance_enabled read through MaintConfig.from_options().

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 20:27:15 +02:00
glenn schrooyenandClaude Opus 5.5 2f261f8ba4 PRICE-01 review: spaced-token check passes the length rule; F3 default pinned
checks / unit (pull_request) Successful in 39m51s
infra-pin / fresh (pull_request) Successful in 1s
The spaced-token check used a 5-character token, which the new 16-character
rule refuses on its own, so a validator that stopped checking spaces passed
(reviewer mutation R06). It now uses a long token and checks the reason.

Also pin F3 with no maintenance_enabled key: the default (off) holds and no
no-price words show (reviewer mutation S04). Tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 19:58:50 +02:00
glenn schrooyenandClaude 0801776645 PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 18:56:51 +02:00
glenn schrooyenandClaude 4eb88f7fda PRICE-01: migration note, and the tests drive the real control cycle
checks / unit (pull_request) Successful in 39m36s
infra-pin / fresh (pull_request) Successful in 1s
- DOCS: the migration note said markup and taxes do not matter; with the
  provider they are added to the wholesale price. Step 4 and 5 now say what
  they set and what static_profile does while no source has a price.
- The gap tests run the notification through cycle(), where the check is
  called, so a check that is never called goes red.
- test header: the real-transport checks are section 11.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 17:03:05 +02:00
glenn schrooyenandClaude 87ad958842 PRICE-01: Dutch dates, no redirects with the token, more checks
checks / unit (pull_request) Successful in 39m2s
infra-pin / fresh (pull_request) Successful in 1s
- The notification, status row and Home line name an earlier day in the
  language of the text ("13 okt om 21:00"); the Dutch title is "Prijzen
  ontbreken", so the body no longer repeats it.
- The token request does not follow redirects: the host is fixed in code.
- A step back up to the saved prices is one INFO line.
- Tests: whole status payload free of the token, a redirect, the 5-decimal
  edge of the old average, modes plan equal to generic_dayahead's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 16:54:11 +02:00
glenn schrooyenandClaude 9ac3a9d2bc PRICE-01: price_service provider, fallback chain, and the gate read from it
checks / unit (pull_request) Successful in 39m31s
infra-pin / fresh (pull_request) Successful in 1s
A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.

The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.

No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.

The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 16:22:24 +02:00
admin 0ce7d35f80 Merge pull request 'MAINT-11: start or stop the maintenance cycle per battery' (#189) from MAINT-11 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-14 01:18:42 +02:00
adminandClaude Opus 5.5 6aa1c32cbb MAINT-11 review round 2: pin each cycle button to its own disabled flag
checks / unit (pull_request) Successful in 38m46s
infra-pin / fresh (pull_request) Successful in 1s
Reviewer mutations survived the round-2 page checks: Start forced always
disabled (|| true), and the two flags swapped between Start and Stop,
because the checks only looked for each flag somewhere in the file. The
check now pins each flag to its own button. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:39:37 +02:00
glenn schrooyenandClaude 5ee1061398 MAINT-11 round 2: disable the cycle buttons that cannot work
checks / unit (pull_request) Successful in 38m43s
infra-pin / fresh (pull_request) Successful in 1s
Stop cycle is disabled on a battery that is not cycling. Start cycle is
disabled while any battery cycles and on a switched-off battery. Page only;
the add-on still refuses each of these. DECISIONS notes the one-battery case.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-13 23:18:21 +02:00
adminandClaude Opus 5.5 fd059126cb MAINT-11 review: test an empty unit at the web layer and the page's own refusal words; drop a stale comment
checks / unit (pull_request) Successful in 38m45s
infra-pin / fresh (pull_request) Successful in 2s
Two reviewer mutations survived the suite: the web layer routing only a
truthy unit (an empty unit then started the bare pick), and the page
showing the server's English instead of its own sentence by code. Both
are now checked. The bare maint_start comment that said a per-battery
unit would ship half a surface is replaced by a pointer to
_maint_unit_action(). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:06:47 +02:00
glenn schrooyenandClaude 21d8a944bc MAINT-11: start or stop the maintenance cycle per battery
checks / unit (pull_request) Successful in 38m46s
infra-pin / fresh (pull_request) Successful in 1s
maint_start and maint_abort take an optional unit. With two or more batteries
each Batteries card has Start cycle and Stop cycle buttons. One slot per site
stays: a start while a battery cycles is refused and names it (no queue). A
battery that is switched off, an unknown unit, and a stop on a battery that is
not cycling are refused with a plain message. A request with no unit is the
old call. The scheduler and the phase machine are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-13 21:37:36 +02:00
admin 79dd0770f9 Merge pull request 'OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)' (#157) from opt15-price-run-fixes into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-13 15:04:11 +02:00
glenn schrooyenandClaude Opus 5.5 7f0f00b01c OPT-15 review: pin the rolled export at 0 W and a battery past E; note the maintenance replay
checks / unit (pull_request) Successful in 38m30s
infra-pin / fresh (pull_request) Successful in 2s
Two survivors of the review's mutations get a check each in
test_price_run_fixes.py (X1: a held export credited negative in the rolled
quarter; X4: the hold extended by a reading already past E). DECISIONS: the
slow-battery quarter (3456 W, same on e3e4ba6) and the replay of a
maintenance phase starting during the hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-13 14:18:41 +02:00
glenn schrooyenandClaude 065792aad3 Merge release/1.0 (e3e4ba6) into opt15-price-run-fixes
infra-pin / fresh (pull_request) Successful in 3s
checks / unit (pull_request) Successful in 39m30s
Conflicts (5 files, 9 hunks) resolved keeping both sides:
- p1.py: held_t (OPT-15) beside held_since (SAFETY-90).
- modetrade.py: chargers() takes targets (P4) and probe (OPT-15); decide()
  clears the chargers, a probing refuser included, while a battery drains
  (SAFETY-78); the docstring lists both rules.
- main.py: the roll goes into the SAFETY-90/91 projection chain
  (`_peak_proj`); the SAFETY-95 drain-tail grid_w keeps the run-edge hold on
  its other branch; the P4 forecast state and the hold state both stay.
- DOCS.md, DECISIONS: both lines kept, OPT-15 item refreshed.

Adaptations the merge needs:
- SAFETY-81 x F1: a rolled projection is not graded by the saturation alarm
  until the quarter's first sample, as the old edge tick was not, so a
  standing overload is still notified once per quarter.
- test_safety81, test_modes_p4: modes-on pins re-pinned (with a note), modes
  off pinned to e3e4ba6; test_price_run_fixes: identity pins on e3e4ba6,
  F1 reproduced at one inverter (SAFETY-91 already holds two), tests for the
  drain and the day plan against the probe.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-13 12:15:44 +02:00
admin e3e4ba674d Merge pull request 'SAFETY-112: say plainly that control waits for Home Assistant to start' (#188) from SAFETY-112 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-13 02:48:31 +02:00
adminandClaude Opus 5.5 df6bc0f76b SAFETY-112 review: grade the waiting flag where hass.py sets it
checks / unit (pull_request) Successful in 36m27s
infra-pin / fresh (pull_request) Successful in 1s
- a 502 or refused read turns waiting on, the next good read off, and a
  read timeout never (two mutants survived without this)
- the flag changes no write with live readings, not only with 502 ones
- the page script's amber dot and batteries-tab banner for "waiting"
- DECISIONS: about 5 minutes per start stage, not "at most"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:10:27 +02:00
adminandClaude Opus 5.5 d94cb0c263 SAFETY-112: say plainly that control waits for Home Assistant to start
checks / unit (pull_request) Successful in 36m38s
infra-pin / fresh (pull_request) Successful in 2s
The Supervisor proxy answers 502 to every add-on read, write and websocket
until Core is RUNNING (proxy.py api()/websocket() -> check_api_state()), so
the add-on cannot control while a slow integration holds HA's start, even
with its entities live. Measured on a safety112 repro stack with the
2026.08.0 proxy code: first write 1.0-4.4 s after RUNNING, 238-300 s after
live readings. Not routed around (DECISIONS/SAFETY-112-...).

While the API does not answer, the first status row is red and is the
banner ("Waiting for Home Assistant to finish starting"), Home shows a
"waiting" state in EN/NL instead of "Needs you now", and the log adds a
"still waiting" line every 5 minutes and the wait's length at the end.
A safety stop, a stalled loop or shadow mode keeps its own headline.
Control, writes, heartbeat and the stand-down are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:07:51 +02:00
admin 3050fa5208 OPT-17: the solar forecast can come from several entities, summed (#187)
infra-pin / fresh (push) Successful in 1s
2026-10-11 05:49:37 +02:00
adminandClaude Opus 5.5 92a80c09b2 OPT-17 review: title the forecast row on Home; test the hint and blank rows
checks / unit (pull_request) Successful in 36m20s
infra-pin / fresh (pull_request) Successful in 2s
The new status row's id `solar_forecast` had no Home title, so Home showed
it under the fallback "Inverter connection". Add "Solar forecast" /
"Zonneprognose" to ui_text, and checks for the title, the tomorrow-gap hint
and blank list rows (review mutations R7, R9, R14 now killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 05:12:35 +02:00
glenn schrooyenandClaude Opus 5.5 9b1fa758ad OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.

- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
  day is summed. Hourly splits are merged when every member has one, else
  the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
  day's forecast unusable (no partial sum). A caution row names it by
  position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
  the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.

DECISIONS/OPT-17-several-forecast-entities.md has the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-11 02:55:58 +02:00
admin e36565fe39 Merge pull request #185: Release 0.17.0
infra-pin / fresh (push) Successful in 2s
checks / unit (pull_request) Successful in 35m11s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-11 00:58:29 +02:00
goodwe-review 808ec04dc4 REL-0.17.0 review: CHANGELOG wording fixes
checks / unit (pull_request) Successful in 35m16s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-107 row names the tested base; MAINT-17 says code reading only;
SAFETY-105 figure is from a simulation; SAFETY-97 edge is 175 W; the
maintenance note cites MAINT-14 and MAINT-15; SAFETY-108 "about 15 s".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:21:39 +02:00
glenn schrooyenandClaude Opus 5.5 35432ce065 Release 0.17.0
checks / unit (pull_request) Successful in 35m11s
infra-pin / fresh (pull_request) Successful in 2s
Bump version to 0.17.0 and add the closed 0.17.0 CHANGELOG section.
No code change, no option change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:29:13 +02:00
adminandClaude Opus 5.5 580c200527 Merge pull request #184: MAINT-15 the balancing hold ends on time with no SoC reading
infra-pin / fresh (push) Successful in 1s
Reviewed at 35b2a69; CI run 1238 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:38:29 +02:00
adminandClaude Opus 5.5 35b2a699de MAINT-15 round 2: in-repo differential check, full-hold with-reading check
checks / unit (pull_request) Successful in 35m8s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:42:01 +02:00
adminandClaude Opus 5.5 cd1bf2a694 MAINT-15: the balancing hold ends on time with no SoC reading
checks / unit (pull_request) Successful in 35m22s
infra-pin / fresh (pull_request) Successful in 1s
HOLD's time check moves into Maintenance._hold(), used by step() and
_no_soc(). A hold that loses its SoC now ends at hold_min, writes
last_completed and logs the same COMPLETE line. The 15 min HOLD line says
the hold still ends at its normal time. Behaviour with a reading is
unchanged (differential trace against db9e231).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:56:29 +02:00
adminandClaude Opus 5.5 db9e2315d1 SAFETY-107: a refusal held under another writer is learned once (#183)
infra-pin / fresh (push) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:29:47 +02:00
adminandClaude Opus 5.5 848140c29f SAFETY-107 review: test a writer that turns round on a dead pack; say when rule 3 applies
checks / unit (pull_request) Successful in 35m22s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:52:39 +02:00
glenn schrooyenandClaude Opus 5.5 d12228d986 SAFETY-107: a refusal held under another writer is learned once
checks / unit (pull_request) Successful in 35m8s
infra-pin / fresh (pull_request) Successful in 2s
At one inverter SAFETY-52's "another writer" end tested target != loop_w.
A held refusal makes the loop ask 0 W, so any binding claim (maintenance
charge, peak guard, trade) ended it every cycle, and compute() learned it
again in the same cycle: "BATTERY REFUSES" / "ENDED: another writer" each
second on the rig. Now the held refusal is judged afresh against the
writer's ask (compute() gets the same inputs as before); it ends only if
compute() no longer finds it. Commands unchanged: 300-seed differential
trace against release/1.0, N=1 and N=2, every command and refusal state
identical. DECISIONS/SAFETY-107.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-09 03:03:06 +02:00
admin bf3ac7998c MAINT-14: maintenance with no SoC reading: charge still times out, drain holds at 0 W (#182)
infra-pin / fresh (push) Successful in 2s
2026-10-08 22:50:52 +02:00
adminandClaude Opus 5.5 61a61941bb MAINT-14 review: tests for a HOLD episode after a no-SoC charge, a MAINT-13 state file, and a true twin site at N=2
checks / unit (pull_request) Successful in 34m56s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:14:26 +02:00
glenn schrooyenandClaude Opus 5.5 ef35f4b925 MAINT-14: accurate no-level wording per phase; name the limit that ended the charge
checks / unit (pull_request) Successful in 34m54s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:25:35 +02:00
glenn schrooyenandClaude Opus 5.5 f39e52efd3 MAINT-14: maintenance with no SoC reading: charge times out, drain holds at 0 W
checks / unit (pull_request) Successful in 35m13s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:37:01 +02:00
admin 8a17711f60 MAINT-13: a full pack that stops at 98-99 % is not a failed charge (#181)
infra-pin / fresh (push) Successful in 2s
2026-10-08 13:45:10 +02:00
adminandClaude Opus 5.5 ea31289ec0 MAINT-13 review: tests for a stale charge record and a clock step back; DOCS names the 7 day end
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 34m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:08:40 +02:00
glenn schrooyenandClaude Opus 5.5 6836f61bd4 MAINT-13 round 3: provider site, hourly fixed-tariff check, faster tests
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 1s
- A price-provider site with no fixed tariff is not a gate (its plan never
  sets `cheap`); DECISIONS §3 says so and a test pins it.
- The fixed-tariff check is worked out once an hour, not every tick.
- test_maint13: run() steps really are `step` minutes; the long loops use
  5-minute steps (the per-tick cap); wall backstop checked "not before".
- test_safety93: the drain bound is the rig's configured drain rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:03:55 +02:00
glenn schrooyenandClaude Sonnet 5.5 f7b83cc75c MAINT-13 round 2: read the price gate without a new main.py option read
checks / unit (pull_request) Successful in 34m35s
infra-pin / fresh (pull_request) Successful in 1s
test_static_tariff forbids main.py reading `static_profile`; a small
static_gated() in static_tariff.py answers it, and the Controller keeps the
answer in `_price_gated` beside `cheap`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-08 11:10:33 +02:00
glenn schrooyenandClaude Sonnet 5.5 6f05b2e4be MAINT-13 round 2: gate open when nothing holds the charge back; safety93 scenario restored
checks / unit (pull_request) Failing after 34m45s
infra-pin / fresh (pull_request) Successful in 2s
- gate_open is true with no price gate set up, in a cheap window, or while the
  pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
  due B takes the slot after A's failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-08 10:29:46 +02:00
glenn schrooyenandClaude Opus 5.5 28eff568b5 MAINT-13: a full pack that stops at 98-99 % is not a failed charge
checks / unit (pull_request) Successful in 34m21s
infra-pin / fresh (pull_request) Successful in 1s
A charge is complete at SoC >= target, or at >= target - 3 once the SoC has
made no new high for 60 min of allowed charging, or when the charging time
runs out at >= target - 3. The 36 h timeout counts only the minutes the price
gate let the pack charge (plus a 7 day wall-clock backstop). A cycle that
fails (stuck drain, charge below target - 3) backs off 7 days, tells the
operator once (EN + NL), and no cycle starts on that pack in that time unless
the operator forces one. State survives a restart; old state files load.

No option added. DECISIONS/MAINT-13-full-pack-is-complete.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:36:06 +02:00
admin b527e99f13 SAFETY-103: no swing on old battery readings after a drain ends (#180)
infra-pin / fresh (push) Successful in 2s
2026-10-08 04:48:26 +02:00
adminandClaude Opus 5.5 c6f23a69d4 SAFETY-103 review: correct the swing after a refusal or lost link in DECISIONS
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 2s
The test table gives 1.5 kW at N=2 and 3.9 kW summed at N=3 under a
surplus, and about 1 kW after a lost link without one; not 1.9 kW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:13:08 +02:00
adminandClaude Opus 5.5 98dd110a4f SAFETY-103: test a dropped battery is not counted; guard a read
checks / unit (pull_request) Successful in 34m26s
infra-pin / fresh (pull_request) Successful in 1s
Mutation M13 (a battery out of the split counted in the house point)
survived; a direct check now kills it. win[0] is read through a guard
(test_named_absence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:12:03 +02:00
adminandClaude Opus 5.5 138e45b53f SAFETY-103: no swing on old battery readings after a drain ends
checks / unit (pull_request) Failing after 35m9s
infra-pin / fresh (pull_request) Successful in 2s
While a battery settles from its ended drain (SAFETY-95), the house point
was grid + every battery's reading. A register can be 10 s older than the
meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5).

Now the house point is held and moved only as far as the meter proves:
each battery is between its last changed reading and its commands since
(a reading that changed this cycle, or a refused side, as it reads).
N=1 and 1 s registers are byte-identical to release/1.0.

DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:25:55 +02:00
admin 451729c9b5 Release 0.16.0 (#178)
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 32m56s
infra-pin / fresh (pull_request) Successful in 2s
2026-10-08 01:02:20 +02:00
goodwe-review 1c607a961a REL-0.16.0 review: CHANGELOG wording fixes
checks / unit (pull_request) Successful in 32m48s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-103 swing 'by about 800 W for about 5 s'; SAFETY-99 'late, by less
than 30 s'; MAINT-10 row: one-at-a-time is not new; SAFETY-96: drop the
test-only 800 W; SAFETY-90 and MAINT-10 rows trimmed to two sentences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:09:08 +02:00
glenn schrooyenandClaude Opus 5.5 756b50ddbb Release 0.16.0
checks / unit (pull_request) Successful in 32m41s
infra-pin / fresh (pull_request) Successful in 1s
version 0.15.0 -> 0.16.0 and a closed ## 0.16.0 CHANGELOG section.
No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:25:14 +02:00
admin 340c7eef42 Merge pull request 'SAFETY-95: a maintenance drain that ends hands back smoothly' (#177) from safety-95-drain-end-reseed into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-07 17:48:53 +02:00
glenn schrooyenandClaude Opus 5.5 4707d37019 SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:41:16 +02:00
glenn schrooyenandClaude Opus 5.5 9c0314a826 SAFETY-95: N=1 identity for every drain end; plainer DOCS line
checks / unit (pull_request) Successful in 32m35s
infra-pin / fresh (pull_request) Successful in 2s
test_safety95.py: the N=1 byte-identity now covers all six drain ends
(Off and stale link were missing); end_at()/measure() read through a
default, so test_named_absence.py passes (it failed on bdec66e); a check
that a dropped battery's start is 0 W even under a pinned charge (it
kills a mutation that survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 bdec66e97a SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)
When a battery's drain ended (done, Off, timeout, abort, stale link,
refusal), its drain floor stayed in the site target and landed on the
others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a
solar surplus B gave into the export.

Now, on that cycle, the loop starts from what the others stood at plus the
battery's new command, raised to the house's own point when the house needs
more (never above last cycle's start). While the battery settles, the loop
counts the batteries at its start instead of chasing the drain's tail on
the meter. Then one slew step a cycle. N=1 is unchanged.

test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line.
test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a
refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:44:25 +02:00
admin b2f55732b6 Merge pull request 'SAFETY-96: a battery the loop cannot move does not freeze it' (#176) from safety-96-stale-unit-site-total into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-06 06:19:12 +02:00
glenn schrooyenandClaude Opus 5.5 956711b801 SAFETY-96 review: keep the refusal count over a refused write; test a fast board
checks / unit (pull_request) Successful in 31m53s
infra-pin / fresh (pull_request) Successful in 2s
F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:46:32 +02:00
glenn schrooyenandClaude Opus 5.5 88b9cd470a SAFETY-96: a battery the loop cannot move does not freeze it
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.

test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:02:03 +02:00
admin 1bf6b7f9a7 Merge pull request 'MAINT-10: maintenance start hour in local time, default 19:00' (#159) from maint-10-local-start-hour into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 23:56:19 +02:00
glenn schrooyenandClaude Opus 5.5 1d28daf8a0 MAINT-10 review: warn when HA did not answer and TZ is UTC; test the 3 s budget
checks / unit (pull_request) Successful in 31m18s
infra-pin / fresh (pull_request) Successful in 1s
HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds,
which the Supervisor sets from HA's zone, so the hour stays right; only a TZ
of UTC there now logs a WARNING asking for a restart. A test pins the 3 s
budget on the zone request (a mutant without it survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:24:02 +02:00
glenn schrooyenandClaude Opus 5.5 0b6b34f563 MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
checks / unit (pull_request) Successful in 31m20s
infra-pin / fresh (pull_request) Successful in 1s
test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:15 +02:00
glenn schrooyenandClaude Opus 5.5 08fb1235fc Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:04 +02:00
admin 7ef73474dc Merge pull request 'SAFETY-90: the peak guard goes on after a P1 meter gap' (#175) from safety-90-peak-guard-gap into release/1.0
infra-pin / fresh (push) Successful in 2s
SAFETY-90: the peak guard goes on after a P1 meter gap (#175)
2026-10-05 13:23:32 +02:00
Claude (SAFETY-90 review) 24dbbd18ad SAFETY-90 review: test that a second gap does not price the first one down
checks / unit (pull_request) Successful in 31m12s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:39:28 +02:00
Claude (SAFETY-90 review) 8dfe91e1d2 SAFETY-90 review: count the start-up seconds like a gap
While the add-on is down the batteries are at 0 W (it sets 0 W when it
stops; the ESP32 watchdog after 30 s), so the import before its first
reading was the house, not the aim. Those seconds now count at
gap_w(limit, the import seen first), and after a restart the restored
reading's held 30 s is topped up the same way. Status row: "counted as
high use". Tests take the pre-start import as the house (batteries at
0 W) and add restarts of 10/60/150 s with a restored averager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:24:33 +02:00
Claude (SAFETY-90 dev) 9d3fbe1be9 SAFETY-90: the peak guard goes on after a P1 meter gap
checks / unit (pull_request) Successful in 31m10s
infra-pin / fresh (pull_request) Successful in 1s
A meter gap past meter_max_age_s left seconds the quarter average never
counted, so the guard was blind for the rest of the quarter (rig case 7b:
3964.8 W against a 3450 W aim). Once readings are back, the missed seconds
now count at the higher of the limit and the import seen just before and
just after the gap with the batteries at 0 W (peakguard.gap_w/fill_gaps);
the gap's first meter_max_age_s is topped up to that too. Seconds before
the add-on's first reading count at the aim, and the page says part of the
period was not measured (EN/NL). The bill record is unchanged; no option.

DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, test_safety90.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:46:58 +02:00
admin 3ed3302d2f Merge pull request 'Release 0.15.0' (#173) from release-0.15.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 29m8s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.15.0 (#173)
2026-10-05 01:52:40 +02:00
glenn schrooyenandClaude Opus 5.5 8c6e5bca2e Release 0.15.0: correct the SAFETY-94/97 row, add the ±170 W known issue
checks / unit (pull_request) Successful in 29m6s
infra-pin / fresh (pull_request) Successful in 1s
Review round 1 (F1-F3) on PR #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:23:03 +02:00
adminandClaude Opus 5.5 f01152b113 Release 0.15.0: bump version and close the CHANGELOG section
checks / unit (pull_request) Successful in 29m1s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.14.0 -> 0.15.0 (minor: behaviour change from
SAFETY-94/97, no option change). New `## 0.15.0` section with the contents
since 0.14.0 (cf705a9 #171, 3129dae #172) and the known issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:48:04 +02:00
admin 3129daed62 Merge pull request 'CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)' (#172) from ci-14-infra-pin-3b4ff84 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 00:45:15 +02:00
glenn schrooyenandClaude Opus 5.5 64dddd64bf CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)
checks / unit (pull_request) Successful in 29m18s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:13:01 +02:00
admin cf705a9058 SAFETY-94 + SAFETY-97: no false stale or refusal on a lagging battery; refusals graded against each battery's idle reading (#171)
infra-pin / fresh (push) Failing after 2s
2026-10-04 17:27:20 +02:00
adminandClaude Opus 5.5 7d06aa674d SAFETY-97: centre the band on the idle reading clamped to 75 W
checks / unit (pull_request) Successful in 29m26s
infra-pin / fresh (pull_request) Failing after 2s
Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:57:16 +02:00
adminandClaude Opus 5.5 88ebf3a5a6 SAFETY-97 review: tests for four surviving mutants
checks / unit (pull_request) Successful in 28m57s
infra-pin / fresh (pull_request) Failing after 2s
The N=1 stale-path refusal and SAFETY-42's parked test are centred on the
idle reading; learning stops while control is stopped or the sign watchdog
has latched. Each had no check (review mutants X7, X8, X13, X14).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:29:27 +02:00
adminandClaude Opus 5.5 3db2b6392f SAFETY-97: test the centred stale-path refusal (mutant S10 survived)
checks / unit (pull_request) Successful in 29m7s
infra-pin / fresh (pull_request) Failing after 1s
At a 5 s meter a register frozen at rest on B trips the stale gate before
the 12-cycle evidence; its refusal must be centred on B's idle reading,
or the site stops and re-trips instead of refusing B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 13:07:06 +02:00
adminandClaude Opus 5.5 80bfc5721e SAFETY-97: point test_safety97 at MUTATION.md; record the DC-entity option
checks / unit (pull_request) Successful in 29m9s
infra-pin / fresh (pull_request) Failing after 1s
test_bytecode requires every suite to name MUTATION.md (CI run 1158).
DECISIONS: why the integration's DC battery power entity is not the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 12:51:52 +02:00
adminandClaude Opus 5.5 6918e154c8 SAFETY-97: "gives nothing" is graded against each battery's idle reading
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s
B's power reading shows -95 to -118 W at idle (standby draw), so the
|reading| < 100 W refusal band never held a refusing B: never at N=2,
and since SAFETY-94's shared rule not at N=1 either (review F1).

Each unit now learns its idle reading (app/idle.py): only while control
runs, the unit is not refused/stale/tripped and its command has stayed
within 50 W for 120 s; first value a 120 s mean, then a 30 min follow;
samples beyond 250 W dropped, value clamped to 250 W; saved per unit and
restored. The shared refusal evidence, compute()'s N=1 engage and end,
the unit refusal ends, the stale-path refusal and SAFETY-42's "parked"
test are centred on it. Until learned it is 0 W, the rule as it was.

Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s
meters), before never; refusing A unchanged. Healthy B with its offset,
SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 12:21:24 +02:00
glenn schrooyenandClaude Opus 5.5 dd5eb6fdc8 SAFETY-94 review: tests for three surviving mutants; record two gaps
checks / unit (pull_request) Successful in 28m7s
infra-pin / fresh (pull_request) Failing after 2s
- test_safety94 part 4: time under 250 W is not counted; a command that
  swaps direction every cycle is never refusal evidence; a reading moving
  away from the ask does not restart the evidence. These kill review
  mutants R1, R3 and R6, which survived the PR's tests.
- DECISIONS: at N=1 a battery with B's standby offset no longer latches
  a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after
  35/d s of wall time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:52 +02:00
adminandClaude Opus 5.5 6759d21ea5 SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s
The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:15:42 +02:00
admin 02ed1200dc Merge pull request 'Release 0.14.0' (#169) from release-0.14.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 27m3s
infra-pin / fresh (pull_request) Successful in 2s
2026-10-03 11:29:13 +02:00
Claude f13dab1407 Release 0.14.0: changelog review fixes
checks / unit (pull_request) Successful in 26m57s
infra-pin / fresh (pull_request) Successful in 1s
Slew step not one house's 400 W; three two-inverter known issues, not
four; commissioning reference covers a new install; SoC example holds
for decimal sensors; SAFETY-95 states the jump, not the export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:01:44 +02:00
glenn schrooyenandClaude Opus 5.5 b307db1049 Release 0.14.0: version bump and changelog
checks / unit (pull_request) Successful in 27m2s
infra-pin / fresh (pull_request) Successful in 2s
Two inverters are now supported for maintenance and the peak guard
(SAFETY-91, SAFETY-93). No option changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:30:25 +02:00
admin e51e3e02b2 Merge pull request 'CI-13: infra-pin to admin/goodwe 0c04488 (FW-05, WITNESS-08)' (#168) from ci-13-infra-pin-0c04488 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-03 10:27:02 +02:00
glenn schrooyenandClaude Opus 5.5 69dc1f0e7b CI: bump infra pin to 0c04488 (admin/goodwe#62 FW-05, #63 WITNESS-08)
checks / unit (pull_request) Successful in 27m26s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:56:36 +02:00
admin 858f77cfb5 Merge pull request 'SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house' (#167) from safety-93-no-grid-charge-at-charge-end into release/1.0
infra-pin / fresh (push) Failing after 2s
2026-10-03 00:58:37 +02:00
adminandClaude Opus 5.5 214051d1a8 SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too
checks / unit (pull_request) Successful in 27m5s
infra-pin / fresh (pull_request) Failing after 1s
A battery still in CHARGE but dropped by the allocator (stale link, or a
SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's
start point, and B went 0 -> -1600 W in one cycle. The end is now noted
after the unit claims are filed: in CHARGE, live and not contradicted.
Tests: part 6 (stale link, refusal) and a spy on the guard suspension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:10:21 +02:00
adminandClaude Opus 5.5 05eb93f139 SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house
checks / unit (pull_request) Successful in 26m55s
infra-pin / fresh (pull_request) Failing after 1s
At N>=2, when a battery's maintenance charge ended, its watts stayed in the
loop's start point and landed on the other battery (B 0 -> -1600 W in one
cycle, charging from the grid at a 6000 W house). On the cycle a charge
ends, the loop now starts from what the others stood at plus that
battery's new command. N=1 and all identity digests unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:38:42 +02:00
admin f3e409cf76 Merge pull request 'SAFETY-91: a held battery is drawn only after the others are full' (#165) from safety-91-held-battery-last into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-02 15:53:33 +02:00
adminandClaude Opus 5.5 4cd604afcb SAFETY-91 review: a held-back battery is released 1 point above its reserve
checks / unit (pull_request) Successful in 26m26s
infra-pin / fresh (pull_request) Successful in 1s
A SoC reading flipping across the reserve swapped ~1000 W between the
batteries on each flip (grid +-500 W with unequal lags). Also grades
next_quarter()'s staleness boundary (29.5 / 30.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:54:20 +02:00
adminandClaude Opus 5.5 6872353e2e SAFETY-91: a held battery is drawn only after the others are full
checks / unit (pull_request) Successful in 26m19s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 a battery at or under its own peak reserve now weighs 0 whenever
the site gives (not only for the peak), a draining battery's pin becomes
a floor at its drain rate so it rises before the held one gives, and a
quarter's edge is graded on the next quarter when its first telegram is
late. SAFETY-25 option A stays: the held battery is still drawn once the
others are full. N=1 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:03:54 +02:00
admin 8c438ab2e6 Release 0.13.0 (#164)
infra-pin / fresh (push) Successful in 2s
checks / unit (pull_request) Successful in 25m53s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-02 10:04:13 +02:00
adminandClaude Opus 5.5 b37b7f95dc REL-0.13.0 review: plainer, truer 0.13.0 changelog
checks / unit (pull_request) Successful in 25m52s
infra-pin / fresh (pull_request) Successful in 1s
Crash, not stop, for SAFETY-61; Watchdog off by default; its own
warning for the 50 W peak margin (also under a higher month peak);
"refuses power" for SAFETY-52/73; no one-house options claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:37:34 +02:00
adminandClaude Opus 5.5 28cab03947 REL-0.13.0: cut 0.13.0
checks / unit (pull_request) Successful in 25m55s
infra-pin / fresh (pull_request) Successful in 2s
Bump config.yaml version 0.12.0 -> 0.13.0 and close a ## 0.13.0
CHANGELOG section covering every merge since 0.12.0 (a45ed06), with the
known issues SAFETY-90/91/92/93/94. Option surface 77 -> 83, none removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:04:47 +02:00
admin 61072ca460 Merge pull request 'SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery' (#163) from safety-85-no-false-not-keeping-up into release/1.0
infra-pin / fresh (push) Successful in 1s
SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery (#163)
2026-10-01 20:52:17 +02:00
adminandClaude Opus 5.5 7b723a6cd5 SAFETY-85 review: grade discharge only, over the plant's settle time
checks / unit (pull_request) Successful in 25m47s
infra-pin / fresh (pull_request) Successful in 2s
Rule 2 graded each battery against last cycle's command and graded charges
too. On plantsim's battery (10 s lag) at the 2 s meter it warned at every
drain or charge start and 15 times an hour under the peak guard at N=2; a
charge tapering near full warned 11 times in 50 min (SAFETY-42 section 7).
Now a discharge is graded against the least it stood at over SHORT_CYCLES /
SHORT_SPAN_S. DOCS lists `mode-bound` under Loop reason. test_safety85 part 7:
peak guard with a dead battery, lagging plant, taper; part 3: the bound ends
with maintenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:25:41 +02:00
glenn schrooyenandClaude Opus 5.5 4b81387908 SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
checks / unit (pull_request) Successful in 25m35s
infra-pin / fresh (pull_request) Successful in 2s
The loop's integrator bound counts only on a cycle the loop decided. A battery
another owner set (maintenance, the peak guard, a safety limit) alarms only
when it gives more than saturation_w less than told for saturation_cycles
cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it
does not wind and control comes back one slew step a cycle. The status row
counts episodes, not cycles. Digests re-pinned where behaviour changed on
purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 19:05:27 +02:00
admin 935878193b Merge pull request 'SAFETY-87: peak guard uses every battery that can still give' (#162) from safety-87-guard-uses-able-batteries into release/1.0
infra-pin / fresh (push) Successful in 2s
SAFETY-87: peak guard uses every battery that can still give (#162)
2026-10-01 12:06:41 +02:00
Claude b4d25dd485 SAFETY-87 review: DOCS, the drain rate may rise for the peak at N>=2
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 24m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 11:08:42 +02:00
Claude a4a04d203c SAFETY-87 review: grade the three surviving review mutants
checks / unit (pull_request) Successful in 25m5s
infra-pin / fresh (pull_request) Successful in 1s
A battery refusing only to charge stays able; the reserve alarm is graded
on the real limit, not the aim; 'control still runs' is set on the two
peak rows only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 11:07:43 +02:00
Claude aa501ffec7 SAFETY-87 review: maintenance pins beside a held battery
checks / unit (pull_request) Successful in 26m5s
infra-pin / fresh (pull_request) Successful in 1s
A draining battery that may still give is raised from its drain rate
towards its rating for the peak floor while another battery is held
(peak > drain); before, the floor's rest landed on the held battery. In a
HOLD, a held battery's own pin counts among the others: a held drain no
longer over-places the site, and a held charge probe no longer parks it.

Tests: test_safety87.py part 10 (drain beside a held battery, N=3 HOLD,
unknown cut-out and unread level at N=3, a stopping row outranks the
running peak alarm). DECISIONS entry updated, incl. the modes-on exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 10:50:01 +02:00
Claude d89c093318 SAFETY-87: grade the surviving mutants (dropped battery, own-floor naming, pair permission, latched row)
checks / unit (pull_request) Successful in 24m56s
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_019DBUfvndgHwaV339Fvd2gr
2026-10-01 09:55:29 +02:00
Claude d0db5b8c39 SAFETY-87: peak guard uses every battery that can still give
checks / unit (pull_request) Successful in 25m45s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 a battery at or under its own peak reserve is held: it gives
nothing for the peak (weight 0 while the site gives) and the guard's need
is placed on the live, non-refusing batteries above theirs. The guard is
floor-limited, and alarms, only when those cannot hold the limit at full
power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold
the Home page at 0 W, because control keeps running. N=1 is byte-identical.

DECISIONS/SAFETY-87-guard-uses-able-batteries.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 09:29:30 +02:00
admin b8ddfd787f Merge pull request 'SAFETY-86: peak guard outranks the maintenance hold' (#161) from safety-86-peak-over-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-01 02:27:36 +02:00
glenn schrooyenandClaude Opus 5.5 1c41a1e412 SAFETY-86 review: hold the site at the peak floor while a HOLD gives
checks / unit (pull_request) Successful in 24m23s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.

Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 02:00:20 +02:00
glenn schrooyenandClaude Opus 5.5 ba31e7c8b1 SAFETY-86: peak guard outranks the maintenance hold
checks / unit (pull_request) Successful in 24m17s
infra-pin / fresh (pull_request) Successful in 1s
During a HOLD the holding battery gives what keeps the quarter at the peak
aim. N=1: the peak floor is refiled at P_MAINTENANCE, as for a drain
(SAFETY-83). N>=2: the hold's pin is raised to the floor minus what the
other live batteries may place, so they give first. A site stop still
decides alone. The hold ends on its clock even if the level dipped.
Below the aim nothing changes (rows identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:56:34 +02:00
glenn schrooyenandClaude Opus 5.5 513183aba3 test_peak_margin: read the month peak on the runs' clock, not today's
Part 3 read the simulated September's peak with the wall clock, so from
2026-10-01 it read no peak and crashed on every tree (4ad8298 too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:56:34 +02:00
admin 4ad8298ead Merge pull request 'SAFETY-84: maintenance charge at N=2 no longer stalls' (#160) from safety-84-n2-maint-charge into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-30 21:00:56 +02:00
adminandClaude Opus 5.5 5c9f64d87a SAFETY-84 review: check the charging pack gives under the guard, and no-P1 at N=2
checks / unit (pull_request) Successful in 23m51s
infra-pin / fresh (pull_request) Successful in 1s
A 6500 W house needs more than B alone: A must give (the N>=2 suspension),
with a non-vacuous run without it (banks over the cap, 'Load too high').
Without P1 the charge is not sized, as at N=1: A charges at 2000 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:36:46 +02:00
glenn schrooyenandClaude Opus 5.5 43a52e7f60 SAFETY-84: maintenance charge at N=2 no longer stalls
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`,
P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the
other pack never has to give against it. The charge is sized to the
quarter's headroom under the peak aim so it yields to the peak guard, and
to 0 W under a site safety stop. N=1 byte-identical (safety84_identity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:47:15 +02:00
admin 9d5b7d129a Merge pull request 'SAFETY-83: peak guard outranks the maintenance drain at N=1' (#158) from safety-83-peak-over-drain into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-30 14:58:41 +02:00
glenn schrooyenandClaude Opus 5.5 bc6d744c6c SAFETY-83 review: banked month peak and no false alarm during a drain
checks / unit (pull_request) Successful in 23m23s
infra-pin / fresh (pull_request) Successful in 1s
test_safety83 part 1: under a month peak banked above the limit the drain
is lifted to peak - margin, and a drain the guard raises (steady or with
5 s spikes that saturate it) gives no 'Load too high' alarm; the part 3
overload still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:41:47 +02:00
glenn schrooyenandClaude Opus 5.5 79b94024cb Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:35:28 +02:00
goodwe-agent 2f5ab1f378 MAINT-10: DECISIONS says where the release note lives
checks / unit (pull_request) Successful in 23m8s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:09:06 +02:00
goodwe-agent fbdd552555 MAINT-10: CHANGELOG draft line moves to the PR (version gate refuses an Unreleased block)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:08:46 +02:00
admin 1145a931ac Merge pull request 'OPT-11 P4: solar forecast and day plan' (#156) from opt11-p4-solar-forecast into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-29 23:01:48 +02:00
adminandClaude Opus 5.5 56cafefa79 SAFETY-83: a refiled peak floor stays under a safety SET, graded directly
checks / unit (pull_request) Successful in 23m32s
infra-pin / fresh (pull_request) Successful in 1s
Kills M5 (refiled at P_SAFETY) on its own sentence, not only through the
per-cycle claim check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:52 +02:00
goodwe-agent 02c0a3dd7b MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:05 +02:00
adminandClaude Opus 5.5 3f69c174af SAFETY-83: grade the refile at its boundary, and N=2 on its own
checks / unit (pull_request) Successful in 23m37s
infra-pin / fresh (pull_request) Successful in 1s
A floor exactly at the drain rate stays at P_PEAK (kills `lo >= rate`), and
part 4's N=2 check no longer shares its failure list with N=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:50:46 +02:00
adminandClaude Opus 5.5 d77f1b1fdf SAFETY-83: peak guard outranks the maintenance drain at N=1
checks / unit (pull_request) Successful in 16m54s
infra-pin / fresh (pull_request) Successful in 1s
At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:40:14 +02:00
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
Claude review c38dd1e969 OPT-15 review: bound unmeasured charge stops, test the hold's rules and M15
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Successful in 1s
- A second unmeasured-headroom stop in a quarter latches the charge: a
  headroom missing every other cycle flipped it 1799 times in 30 min.
- Unit test of the run-edge hold's end conditions and of a small step.
- Outside its probe a refuser does not make the others rest (M15 red).
- DECISIONS: one review line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:31:52 +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
adminandClaude Opus 5.5 fae6681e19 OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.

F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.

F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.

Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:32 +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
243 changed files with 57178 additions and 3616 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").
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
+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: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
for the EMS-port wording and the `slew_w` row only. 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
+1 -1
View File
@@ -156,7 +156,7 @@ that looks hung gets deleted.
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
Platform comes from characters 6–8 of the serial number. Only
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
different protocol. The reference unit is `BPS`.
+17 -8
View File
@@ -1,8 +1,8 @@
# GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
net grid exchange at zero by sending the inverter a grid reading over its RS485
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
```
repository.yaml add-on repository metadata
@@ -30,7 +30,7 @@ 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; rebuilt 2026-10-17, content still v0.1.0 and 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
@@ -50,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
@@ -79,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
@@ -102,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
@@ -109,17 +118,17 @@ the platform tag:
| tag | platform | verdict |
|---|---|---|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different meter
A "-20" or "G2" suffix means a different platform with a different
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
- GoodWe **ES / BP family** inverters (AA55 generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a
firmware change on GoodWe's side could break every installation at once. Say
so when you sell it.
+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.
Binary file not shown.
+8 -8
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>
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter
A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
@@ -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>
+8 -8
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>
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter
A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
@@ -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

+911 -3
View File
@@ -1,5 +1,894 @@
# Changelog
## 0.18.2
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.1 also
start 0.18.2. Only a forecast sensor with no power unit needs a look (below).
The change below applies with one inverter or two.
**A forecast sensor with no power unit.** If your 15-minute demand forecast
sensor has no unit, or a unit that is not power (for example kWh), the add-on
now ignores that forecast. The log warns once, and the Home and Settings pages
say so while it lasts. Pick a sensor that reads in W or kW.
Contents of this release, in merge order since 0.18.1 (`780c1f6`):
| | |
|---|---|
| `09a5485` | The 15-minute demand forecast sensor is now read in its own unit, W or kW, and the old rule that took any value below 50 as kW is gone. With no unit, or a unit that is not power, the forecast is not used, and the log and the pages say so; the settings page no longer says a kW sensor is taken as watts ([[SAFETY-118]], #198) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.1
**A patch bump: two small fixes to the settings page, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.0 also
start 0.18.1. The changes below apply with one inverter or two.
**Price rounding on 0.18.0.** On 0.18.0, the new settings page saved the fixed,
day and night prices rounded to 2 decimals (0.34512 became 0.35); prices in time
windows kept their decimals. If you typed more than 2 decimals in those
fields on 0.18.0, enter them again.
Contents of this release, in merge order since 0.18.0 (`a0e091c`):
| | |
|---|---|
| `b703aaa` | The four tariff price fields on the new settings page (fixed, day, night and time windows) take 5 decimals, for example 0.34512 €/kWh, and are saved as typed. A short hint says to divide the c€/kWh on your bill by 100 ([[UX-43]], #194) |
| `8e02a09` | The dynamic contract choice on the new settings page now says "Dynamic, every 15 minutes" ("Dynamisch, per kwartier") instead of "hourly". The choice works as before, and one line in the docs no longer calls dynamic prices hourly ([[UX-44]], #195) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- If the 15-minute demand forecast sensor reads in kW, the settings page warns that its number is taken as watts and asks for a sensor in W. The add-on does turn kW into W, so a kW sensor works ([[SAFETY-118]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.0
**A minor bump: new behaviour, three new options and one new value for an
existing option.** No option was removed or renamed, and no default of an
existing option changed (83 options -> 86). **Existing installs need no option
change:** options that start 0.17.0 also start 0.18.0, and the new options are
off or empty until you use them. The changes below apply with one inverter or
two, unless a line says "Two inverters".
New options, and what they do if you leave them alone:
| option | default | left alone |
|---|---|---|
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
`price_provider` also accepts a new value, `price_service`. Its default is
unchanged, so the price source stays as it is.
**Price service.** The new price source `price_service` reads prices from the
add-on's price server, with the token you were given. If it does not answer,
prices come from energy-charts.info, then from the prices the add-on saved.
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
sensors to `price_service`".
Contents of this release, in merge order since 0.17.0 (`e36565f`):
| | |
|---|---|
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.17.0
**A minor bump: changed behaviour, and no option changes.** No option was
added, removed or renamed, and no default changed (83 options). Options you
saved work as before. The changes below apply with one inverter or two, unless
a line says "Two inverters".
**Maintenance with no battery level.** When the battery level is missing,
battery maintenance now ends on its own time limits instead of waiting for the
level to come back (MAINT-14, MAINT-15). You do not need to change any option.
Contents of this release, in merge order since 0.16.0 (`451729c`):
| | |
|---|---|
| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) |
| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) |
| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) |
| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) |
| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.16.0
**A minor bump: changed behaviour, no option added or removed, and one default
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
used to be read as UTC and its default was 10. It is now a local hour (in Home
Assistant's time zone) and its default is 19. The other changes below apply
with one inverter or two, unless a line says "Two inverters".
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
it now means local time. If you set it in UTC to get an evening start (for
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
installs that have no hour saved.
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
| | |
|---|---|
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.15.0
**A minor bump: changed behaviour, and no option changes.** No option was
added, removed or renamed, and no default changed (83 options). Options that
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
with two.
The add-on now learns and saves, per battery, what its power reading shows
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
uses 0 W until then, as before. There is nothing to set.
Contents of this release, in merge order since 0.14.0 (`02ed120`):
| | |
|---|---|
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.14.0
**A minor bump: new behaviour with two inverters, and no option changes.**
No option was added, removed or renamed, and no default changed (83
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
control is the same as in 0.13.0.
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
charge, hold) and the peak guard. To add a second inverter, follow
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
add-on takes control of it, needs 0.14.0. Read the known issues below first:
three of them are about two inverters.
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
| | |
|---|---|
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.13.0
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
Setup), and six new options.** No option was removed or renamed, and no
default of an existing option changed (77 options -> 83). The only new
start-up checks are on the new options, so options that start 0.12.0 also
start 0.13.0.
New options, and what they do if you leave them alone:
| option | default | left alone |
|---|---|---|
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
[[DOC-5]]).
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
peak limit, so the batteries give a bit more at peak times. Set
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
| | |
|---|---|
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
| `fe13251` | CI pin update (CI only) |
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
| `59415ea` | CI pin update (CI only) |
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
| `5c151a3` | CI pin update (CI only) |
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
| `125b070` | CI pin update (CI only) |
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
| `c4032eb` | CI pin update (CI only) |
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
| `515c11b` | New Installer settings view ([[UX-13]]) |
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 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
@@ -50,8 +939,13 @@ operator can observe on an installed add-on - and are excluded for the reason
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 are open, both are wording or bound
changes rather than deadlocks, and neither was allowed to delay this one.
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
@@ -168,6 +1062,17 @@ a clearance the state could not produce. ⚠️ Its **diagnosis** half still say
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
@@ -389,7 +1294,10 @@ out of it that no test in this repository had reached**: [[SAFETY-56]] 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.
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`):
@@ -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,67 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -0,0 +1,37 @@
# MAINT-11 - start or stop the maintenance cycle per battery
**Status: for review.** Tests: `test_maint11.py`. No option added.
**The fault.** At two or more batteries the page had one "start a battery cycle"
button, and `_maint_button()` chose the battery. An operator could not cycle a
chosen one (inverter B, new after storage, needed its first cycle).
## Rule
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
run the old code unchanged. The Batteries screen sends the unit from a
per-battery Start cycle and Stop cycle button, at two or more batteries only.
2. **A start while any battery holds the maintenance slot is refused, not
queued.** The message names the battery that is cycling. One slot per site
stays the rule, and the scheduler is untouched.
3. Also refused, with a plain message: an unknown unit, a start on a battery
that is switched off (battery modes on), and a stop on a battery that is not
cycling. Nothing changes on a refusal.
4. A stop on the cycling battery is today's abort: the cycle ends, then every
inverter goes to 0 W.
## Why refuse and not queue
A queued start is a cycle that begins hours later, unseen, after the operator
has moved on. A refusal is immediate and says why. The scheduler already
queues by waiting time, so nothing is lost.
## What this does not cover
- Today's bare button never refused a battery that is refusing to give power
(SAFETY-73); its drain waits. The per-battery start does the same.
- A start on the battery that is already cycling is refused as "busy", where
the bare button restarts its drain. The bare button is unchanged.
- At one battery, a request that names the battery uses the new path, so a
second start is refused instead of restarting the drain. Nothing sends a name
at one battery, and the bare request is unchanged.
@@ -0,0 +1,109 @@
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
is a constant in `app/maintenance.py`.
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
not set, so B was still due and would have drained again from 98 % to 11 % at the
next start hour, with no notice. Three faults: a pack may never report 100 %
while the BMS balances; a failed cycle came back the next day; and the 36 h
counted hours the price gate kept the pack waiting.
## 1. The charge is complete when
1. SoC >= target (as before), or
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
counts as done", "charge time is up at 98 %, close enough to full ...").
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
invented. SoC also covers the case where power flows but nothing is stored.
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
06:24) without a new high, so 60 min is well inside it and long enough that a
pack still creeping up (one new high within the hour) keeps resetting the wait.
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
step up more slowly than that, raise the constant. Falling back by one point
does not reset the wait; only a new best does.
## 2. A failed cycle backs off 7 days
A drain stuck 12 h or a charge that times out below target - 3 is a failure
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
stored, and `wants_start()` is false until 7 days later. The operator's forced
start ignores it; a cycle that completes after the failure ends it. An operator
abort, an interlock stop and a switched-off pack are not failures.
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
short cause (several dull days with no cheap hours, a pack switched off), and
waiting the full interval leaves the coulomb counter uncalibrated for a month
more. A week is long enough that a bad pack costs one drain a week, with one
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
the notice says so each time.
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
persistent notification "Battery cycle did not finish" (EN, with the Dutch
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
wipe it, so the status page shows a "Maintenance paused" row with the date for
as long as the wait lasts. The operator may also see the log lines.
## 3. The timeout counts only allowed time
`Controller._charge_gate_open()` says whether the pack may charge now. It is
true when no price gate is set up (no price entities, no fixed tariff: nothing
holds the charge back, and `cheap` is always false there), in a cheap window, or
while the pack is really taking charge (battery power at or below -50 W, which is
sun outside cheap hours). It is false only when a gate exists, is shut, and the
pack is not charging; a pack asked for charge that delivers nothing then does not
count, which is the case the clock cannot judge. A site with a price provider
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
top-up there and only the sun charges the pack, as on a site with no tariff. Its
time counts in full. When a provider plan starts to drive `cheap`,
`_price_gated` must become true for it. Whether a fixed tariff is set up is
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
while the gate is closed. The accounting is written to the state file at most
every 5 min (`charge_track`); a restart loses at most that. A state file from
before this change, or with no record for the current charge, starts from the
wall-clock minutes so far, which is the old behaviour.
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
complete at >= target - 3, a failure below.
**MAINT-12.** This is the smallest change that makes the timeout honest: one
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
minutes it plans are the allowed ones) without touching the rest. Two things it
must keep: the back-off is in `wants_start()`, so any new entry point (such as
`price_forecast.begin_phase`, which is not wired today) must go through it or
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
charge now". The peak guard pausing the charge shaping is not seen as a closed
gate: that time still counts.
## 4. What does not change, and what this does not cover
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
backed-off pack does not want a start, so at N=2 the other pack may take the
slot. One pack at a time at N=1 and N=2.
- `last_failed` is a maintenance key and moves with the others when a second
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
restarts from the wall-clock minutes.
- It does not find out why a pack stopped. A pack that stops at 96 % and never
gets near is still a failure.
- The Dutch text is only in the notification. The status row and the log lines
are English, like the rest of the status page.
- It does not change the target: a site that wants a different stop point sets
`maintenance_soc_target`.
@@ -0,0 +1,68 @@
# MAINT-14 - no SoC reading in a maintenance cycle
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
number is a constant in `app/maintenance.py`.
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
before any timeout check. (1) A charge on a pack whose SoC reading was lost
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
with an unknown level, which breaks the TEL-08 rule that an unknown level never
allows discharge.
## Rules
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
minutes) and the 7-day wall backstop still run. When one fires: if the last
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
and its one notification. If no SoC was ever seen, it fails.
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
does. The 12 h drain timeout still runs and fails as before.
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
what it was: it paused while the reading was missing, so a gap never counts
as "no more charge".
4. **HOLD** is unchanged (0 W).
5. **Log.** One line when a drain starts holding for this reason ("the battery
level is unknown, so the drain holds at 0 W until it is back"), and one line
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
once per episode; the old line that repeated every tick is gone.
## Why
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
before the reading went is not close to full. Only the last reading counts.
It is saved with the charge record (`charge_track.last`), and saved at once
when the reading first goes missing, so a restart mid-gap still knows it
(within the usual 5 min save interval otherwise).
- **Failure when nothing was seen.** With no level we cannot call a pack full.
The failure costs a 7-day wait and one notice; calling it done costs a
missed balance and a month-long false "complete". The first is the honest one.
- **Timeouts count the same minutes as with a reading.** Gate-open charging
minutes only; the wall backstop for a gate that never opens.
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
line; a reading lost for a quarter of an hour is worth one. Raise it if the
action log shows noise.
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
away the part already drained. Holding at 0 W loses nothing and the 12 h
timeout is the limit.
## Cost
- A drain that loses its reading stops draining, and the 12 h clock keeps
running. A gap of many hours can therefore fail a drain that was working. The
back-off then applies (7 days).
- A charge whose reading is lost early (last SoC below target - 3) fails at the
timeout, even if the pack was in fact filling. The same 7-day wait applies.
## What this does not cover
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
- It does not tell the operator why the reading is missing (sensor, register,
link). It does not reuse the stale-input gate; it is the cycle's own rule.
- The charge itself is still driven by the control loop, which has its own
handling of a missing SoC (TEL-08 for the loop is a separate card).
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
long" line may be said again after a restart: the episode start is in memory.
- Text added is two log lines, English only, like the other log lines.
@@ -0,0 +1,47 @@
# MAINT-15 - the balancing hold ends on time with no SoC reading
**Status: for review.** Tests: `test_maint15.py`. No option added.
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
HOLD that returned 0 W without the time check. A hold that lost its reading
never ended: `last_completed` was never written and the next due date never
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
in MAINT-14.
## Rule
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
`Maintenance._hold()`, holds the time check and is used by both paths. When
the time is up it writes `last_completed`, goes IDLE and logs the same
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
still ends at its normal time." Still once per episode.
## Why
HOLD is reached only after the charge counted as done. Every entry into HOLD
is in `step()` or `_no_soc()` and follows a charge that reached the target,
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
charge). The hold is a wait for the BMS to balance; it never needed the level.
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
line is true without a reading: it names minutes of hold, not a level.
## Cost
- If the level was lost during the hold, the cycle is recorded as complete
without anyone seeing the final level. The charge had counted as done, so this
is the same trust MAINT-14 already gives a charge that ends with no reading.
## What this does not cover
- It does not check that the pack is still full at the end of the hold.
- It does not say why the reading was lost.
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
workspace, not in this repository.
- A state file written by an older version with `phase: hold` loads and ends on
time; the file is not changed in format.
@@ -0,0 +1,203 @@
# 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) and, since OPT-15, holds it there until the meter shows the step.
- 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.
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
`test_price_run_fixes.py`):
- F1, a charge stopped at a quarter edge and latched for the quarter. The
P1 averager opens a quarter only when a sample arrives, and the HA source
builds one 0.35 s after the reading, so the cycle on the boundary still
saw the old block: "no seconds left", headroom None, "no trade
(headroom)", latched for the new quarter. With modes on the guard now
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
crediting the held sample as the averager will), and only a measured
stop latches. Modes off still reads the old block (identity).
Since SAFETY-91, `next_quarter()` does the same for the guard at two
inverters (no stop there without the roll, same banked quarters); the
roll is what covers one inverter, where the old projection stops the
charge at every edge. With modes on at one inverter the roll also removes
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
read "no seconds left"), so a standing overload is still notified once
per quarter.
- F2, -1120 W export at a charge end against -257 W modelled. The replay
acted every 5 s on readings of that instant, and by then the lagged
battery had nearly arrived, so it never acted on a reading that still
showed the trade. The add-on cycles on each meter update (1 s on the rig)
on readings 1-2 s old: the two cycles after the reseed saw the trade's
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
still moves). The replay gained `Lag` (dead time, reading age, the P1
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
numbers hold. The hold gives the loop the target as its grid, so it
yields to SAFETY-95's drain tail, and any other claim that moves the
command ends it.
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
stands only while the site asks that way, and nothing asked to charge.
Probing was chosen over changing the card: the probe is 200 W for 34 s
each 15 min under the same peak gates, and it wins back the run. In its
probe window the refusing battery is a charger, pinned at 200 W by its
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
first, and with a day plan a probing Leftover solar battery goes only to
its planned level. At one inverter SAFETY-52 already ends a charge
refusal as soon as the house asks for power.
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
reading older than the meter can make a charge start look like a house
over the aim, a real guard stop.
- Review: an unmeasured stop that never latched let a headroom missing
every other cycle flip the charge 1799 times in 30 min, so the second one
in a quarter latches (at most 4 changes a quarter). A house that rises
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
before its end (two inverters, lagged readings): the hold adds at most
+33 W to that quarter against the loop without it, and the most any
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
e3e4ba6). The import during the hold is the rise, below the charge's
own, and a rise past that is the peak floor's, which ends the hold.
A maintenance drain, hold or charge that starts during the hold ends it
on that tick (replayed at edge + 3 s).
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
The trade was sized at the probing battery's rating (2500 W), B's pin held
200 W of it, and the allocator put the other 2300 W on A, in its
maintenance charge phase (no mode range there, so nothing kept it out):
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
what the other chargers would have had without the probe (none: 200 W
alone), and it carries the pin only on the ticks the pin is filed (the
window's first and last cycle moved another battery's share by 200 W).
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
normal charger its command is the same and the grid rises 200 W (about
7 W on the quarter) rather than that charger giving 200 W up. A probe
that the headroom or the bound cannot hold at 200 W is not filed.
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
give (one sign), so an idle-looking A that gives the house stops for the
window and the grid rises by what it gave. The trade line now says 200 W
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
trade on a battery in maintenance charge) is not touched.
## 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,79 @@
# OPT-17 - the solar forecast from several entities, summed
**Status: for review.** Tests: `test_opt17.py`. Two options added.
A site whose forecast comes as several Home Assistant sensors (one per
inverter or roof side) had to build a template sensor to sum them. Now each
day can list them.
## Options
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
sensors, default `[]`, the same shape as `pv_entities`.
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
install that uses them needs no edit.
- **Why new keys, not "string or list" in the old ones.** Measured on
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
string an install already stores (`Invalid list for option
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
spelling that takes both, `match(.*)?`, also takes the integer 417 and
shows a plain text box, so a second sensor cannot be added in the UI.
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
the lists render as repeatable fields; a downgrade drops them and keeps
the single keys.
- `[]` in `options:` is load-bearing, as for `pv_entities`.
## Rules
1. **One key per day.** Setting the single key and the list for the same day
is refused at start, with one message: keep the list, clear the single one.
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
`pv_entity` with a caution row. Here the brief asked for a refusal: a
doubled or dropped forecast changes what a battery buys, and no install
has both keys yet, so the refusal breaks nothing.)
2. **Each member in its own unit.** Every member is read as one forecast
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
day's kWh are added up. A repeated id counts once.
3. **One member not usable means the day is not usable.** Missing,
unavailable, unknown, stale, not an energy unit, or a bad number: that
day's sum is not used, exactly as when the single sensor reads that way.
No today means no forecast (P3 option A). No tomorrow means today only.
4. **Hourly split.** If every member has a split, the splits are joined
(overlapping periods add up). If any member has none, the sum is spread
over the bell, as for a state-only sensor.
5. **Time.** The forecast is as old as its oldest member.
6. **One read.** All members of both days are read in one gather, inside the
gather that reads the inverters, so a slow sensor does not delay control.
7. **Shown and logged once.** With two or more members, each one not usable
is named on the status page by its place in the list (STYLE.md: no entity
ids on the page), as a caution row titled "Solar forecast" on Home. The
action log names the entity id, once when the set of failing members
changes.
8. **One member is the single key.** A one-member list reads, plans, shows
and logs exactly as the single key with that entity.
## Why
- A partial sum is a smaller number that looks real. Two of three inverters'
forecasts read as a duller day, and Leftover solar would then buy grid
energy the sun was going to give. TEL-12 took the same rule for the same
reason. No forecast is a known state with a tested fallback.
- The bell for a mixed list: joining one member's hours with another's total
has no honest shape, and the bell is the existing state-only rule.
## Cost
- One flaky member costs the whole day's forecast. A site with three
sensors loses the plan more often than one with a template sum that hides
gaps.
- Two more options (85).
## What this does not cover
- No weighting or calibration per member; FCST-01 is still the own forecast.
- Members on different integrations with different refresh times: each must
be fresh (3 h) on its own; there is no wait for a lagging one.
- The day plan itself, the trust and the house profile are unchanged.
- The Batteries screen still says only "no solar forecast now". The member
is named on the status page.
@@ -0,0 +1,117 @@
# PRICE-01 - the owner's price server as the price source, with fallbacks
**Status: for review.** Tests: `test_price_service.py`. One option added,
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
card answers for one provider. Design: the owner's price service note of
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
## What it is
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
plus `covers`, so the existing columnar parser reads it. The price is wholesale
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
## Rules
1. **The host is a constant in the plugin**, declared as its only egress host,
HTTPS only. The operator's `price_feed_url` is not read by this provider.
*Cost:* every client talks to the one host, and moving the server is a new
add-on version. *What replaces the per-site setting:* the egress allow-list,
TLS, and a token per client that the server can revoke.
2. **The chain is server, then energy-charts, then the saved prices**
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
server that answers with old prices is skipped like one that is down. A step
down and a step up are each one log line, written when the step changes. The
operator's `price_feed_url` is cleared for the energy-charts step, which is
asked for its own feed.
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
already keeps (the last three Brussels days of what was published). They are
the all-in prices as stored; a change of markup shows from the next fetch
that works. A read from the store is not filed again.
4. **No price for now** means no published price covers the present moment. Not
"none for the coming hours": before about 13:00 tomorrow's prices are not
published on any healthy day, and carried hours are already marked estimated.
It sets one status row (*Prices*), one Home plan line (English and Dutch),
and one notification (*No prices*, English and Dutch), taken down when a
price returns. A start-up before the first refresh finished is not a gap.
The notification is not re-asserted after a Home Assistant core restart
(SAFETY-34 does that for latched safety alarms); the status row stays.
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
run when the *current 15-minute price is at or below today's average*, today
being the Brussels day, computed from the provider's prices. This is the rule
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
the pair to the provider does not change when the maintenance charge runs.
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
hours of the whole plan). This replaces, for this provider only,
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
writers: the pair beside a provider stays refused; `static_profile` beside
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
did. (3) The basis is the one above, not `cheap_now()`.
6. **The token is a secret.** `password?` in the schema; hidden on the settings
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
never put in a message by the plugin; taken out of any reason text by
`price.scrub()`; the real transport reports only a status or an exception's
name. validate.py refuses `price_service` with no token, a token with a
space or line break, and one under 16 characters (so `scrub()`, which skips
values under 4 characters, always has something to take out). Round 2: the
token is in no page and no JSON answer of the web app. Every `password`
option of the schema is shown as `********` (set) or empty, in
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
classic page draws it as a password field; a save or a validate that sends
`********` or nothing keeps the stored value, any other value replaces it.
A token can therefore be changed but not cleared from the page.
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
cheap window and the gate ignore it, because they read the provider's
prices. Keeping it set is the recommended setup, and clearing it triggers the
"static_profile is empty" warning, so no notification (round 2's *Fixed
tariff not used* was false and fired on every sound start; round 3 removed
it). Instead one INFO line at start (English, as every log line) and an info
note beside the setting (EN and NL, "With the price service, the fixed
tariff is only used if no prices are available."), both
only with a non-blank `static_profile` (whitespace counts as blank). At
start the add-on dismisses the old notification id, in case a pre-release
build raised it.
8. **The no-price words show only with the maintenance charge on** (owner,
2026-10-14): the status row, the Home line and the notification say
'maintenance waits', so with `maintenance_enabled` off none is shown.
## Where the gate equals the Home Assistant pair, and where it does not
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
rules in `price_service.yaml` (price at the quarter, average of today's
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
clock-change days, at three instants in every period, and through a real
`Controller` for a whole day. Every answer is the same.
Not equal, by design or by arithmetic:
- The comparison is made on all-in prices. With a flat taxes figure and a
markup and taxes of at most five decimals the offset cancels and the answer
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
prices differ by hour, so the answer can differ from the wholesale comparison
the HA sensors made. A site on that profile should check before moving.
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
have more than five decimals.
- The plan is retired when no refresh has succeeded for 90 minutes
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
answer after its REST sensor stopped updating was not measured here.
- The average is over the firm prices the provider holds for today. After a
restart in the middle of a day with only the saved part of today, it is the
average of that part.
## Not covered
- **No real answer of the server was seen.** No token was available. The
layout is the design's; `test_price_service.py` uses a mock and, for the
parser, the real energy-charts capture reshaped with the design's extra keys.
The first capture from the live server (rig test) should replace the mock.
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
30 minutes, and at most two requests when the server fails.
- Battery modes read the plan as for `generic_dayahead` and were not changed.
- The `price_service` REST and template sensors in Home Assistant are not
touched; they stop being read when the pair is cleared.
@@ -0,0 +1,80 @@
# SAFETY-103 - no swing on old battery readings after a drain ends
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
from its ended drain, SAFETY-95 steers on the house's point: grid + every
battery's reading - aim. The meter is 1 s old; a battery register can be
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
while a drain's tail settles):
1. Each cycle, per battery: the range its power can be in. That is its last
changed reading, widened by every command on its wire since (a lagging
battery moves from one toward the others, never beyond). A new reading
starts the range again.
2. A reading that changed this cycle is taken as it reads. So is the side a
battery refuses (SAFETY-73): it is not heading that way.
3. The house point is held (first cycle: the loop's start from SAFETY-95).
It moves only as far as the meter proves: to the nearest value of grid +
the batteries' ranges - aim.
So the loop no longer moves on a reading that has not caught up. A real
house step still moves it, as soon as the meter shows more than the
batteries can explain.
**Why not the tester's rule** ("a battery whose reading is older than its
last command counts at its command"). B's command changes almost every
cycle, so B is almost always at its command. That is the plain meter, which
chases A's tail: B swings 1420 W. A lag model needs the plant's time
constant, which is not known for a client's inverter. Measured on the plant
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|---|---|---|---|---|
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
| lag model, tau 5 s, plant 20 s | | | 600 | |
| hold all window | 0 | 0 | 0 | 0 |
| **this rule** | **0** | **0** | **0** | **130** |
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
exported 16.9 Wh (release 8.5, this rule 8.9).
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
at -100 W; summed swing of the others in the 23 s after the end):
| case | release/1.0 | after |
|---|---|---|
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
| registers as fresh as the meter (1 s) | | rows byte-identical |
| refusal end | | rows byte-identical |
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
**Cost.** In the window, a house change smaller than what the batteries
could still be doing is not followed until a reading catches up (at most a
register period) or the window ends (23 s). Under a surplus the others
still swing up to 130 W, a third of a slew step.
**Not covered.**
- Outside the drain tail the loop steers on the meter; readings only reach
the saturation freeze (SAFETY-96), unchanged.
- A lost link or a refusal ends the tail at once (A is out of the split, or
reads ~0 W). The swing after them is the loop's own on a lagging plant,
the same with 1 s registers and on release/1.0: under a surplus up to
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
even without one. Not this card.
- A battery that overshoots its command, or a reading offset beyond the
range, can put the true power outside the range; the meter then moves the
point, as before.
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
batteries were steady during the drain, so they are current there.
@@ -0,0 +1,96 @@
# SAFETY-107 - a refusal held under another writer is learned once
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
maintenance charge in a cheap window takes no charge. At N=1 the action log then
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
log flooded and the hint under the buttons changed every second, so the
MAINT-13 notice was gone at once.
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
cycle. The refusal was ended against the same writer it had been learned
against. `sat_count` is not reset by that end, so `compute()` learned the same
refusal again in the same cycle. Nothing about the battery or the wire had
changed; only the log and the hint did.
**Other writers.** The same flap comes from any writer that holds the wire
while the loop is refused, as long as the evidence lasts: a trade (a SET at
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
release/1.0).
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
probe, exactly as before the change. Then:
1. it finds the same refusal against that writer's ask: the same episode
continues, with no line and no hint. The probe clock restarts, as it did on
the old end and start: a probe is the loop's ask, and it is not on the wire
while another writer is;
2. it does not: the refusal ends, logged "another writer", or "the battery
gives power" when the reading shows it (it was "another writer" before);
3. it finds the other direction: end, then a new start, as before. In
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
sign, so a writer that turns round ends the refusal at once and the other
way is learned ~12 cycles later (`test_safety107.py` part 3e).
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
longer describes what is on the wire, judged by the same test that learns it.
`compute()` sees the same inputs as before and the state after each cycle is
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
Keeping the held refusal into `compute()` instead (mutation M5) would also
stop the lines, but it holds the loop at 0 W where the old law let it ask past
a writer's limit, which changes commands; it also removes SAFETY-52's
owner-decided "another writer" exit.
**Evidence.**
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
tree): 300 seeds, N=1 (153) and
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
house, maintenance charge with the cheap window opening and closing, P1 and
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
refusal held. Every command, site target, integrator, `sat_count`, refusal
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
commands differ from release/1.0):
| | mutation | reds | trace |
|---|---|---|---|
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
M4 is equivalent: a probe matters only if `compute()` engages in that call,
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
cycles restarted the clock. The line stays because it states the rule.
**Cost.** None in power. A takeover by a different writer while the battery
still refuses is logged as an end and, ~12 cycles later, a new start against
that writer (3 lines for the episode, `test_safety107.py` part 3a).
**Not covered.**
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
writer's ask returns fresh, `compute()` has no saturation history against it,
so the refusal ends at the first cycle and the next trip seeds it again: 2
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
changes commands. None of the rig runs showed it: real readings move.
- At N=1 a refusal shapes only the loop. A writer above it still asks the
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
@@ -0,0 +1,98 @@
# SAFETY-112 - control waits for Home Assistant to finish starting
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
Control is unchanged. The add-on now says plainly that it is waiting.
## Cause
The Supervisor's proxy refuses every add-on request with 502 until Core's
state is `RUNNING`. The add-on reads every battery reading and writes every
power request through that proxy, so it cannot control until then. Readings
that are already live inside Home Assistant do not help.
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
`number.set_value`) and `APIProxy.websocket()` both start with
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
comes from Core's `/api/core/state`.
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
- A Core restart restarts only the Core container
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
for an event. The loop reads its inputs every second, a 502 gives `None`,
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
the meter straight from the HomeWizard (`homewizard_local`), not through Home
Assistant.
## Measured
Repro stack `safety112-*`, all removed afterwards:
- Home Assistant 2026.9.4, as live.
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
- A fake meter and two fake inverters that update every 2 s from load.
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
image. Only the add-on token lookup, Core's access token and the container
check are stubbed.
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
Times are seconds after `docker restart` of Home Assistant:
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|---|---|---|---|---|---|---|
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
stage timeout), so one slow integration holds a start stage for about 5
minutes. The whole start can take longer: the live one took 414 s.
Before this change the page said "Not ready — Battery level: no usable
reading", which sends the reader to Developer Tools for an entity that is
fine.
## Rule
1. No way around the Supervisor. The add-on still talks to Home Assistant
only through the proxy.
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
first status row is red: "Home Assistant is not answering, perhaps it is
still starting. Control resumes by itself when it answers." The banner
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
pill reads "Waiting for Home Assistant", in English and Dutch.
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
and state over the wait, because none of them clears by itself.
4. The log has one WARNING when Home Assistant stops answering, an INFO line
every 5 minutes while it is down, and an INFO line with the length of the
wait when it answers again.
5. Only the API counts. The meter websocket alone does not, because the meter
has its own row.
## Why it is safe
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
the same targets and writes with the flag on and off, for N=1 and N=2.
## Open, for the owner
The add-on cannot control while Home Assistant starts. These ways around it
were not taken:
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
answered from 12-13 s, and a direct `number.set_value` 10 s into
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
at every client site, which `hass.py` exists to avoid.
- **Talk to the control board directly** (ESPHome native API), so control
does not need Home Assistant at all. This is a large change.
- **Remove the slow start at the site.** On 2026-10-12 the Solis
integration's discovery held the start. Fixing or disabling it shortens the
wait there, but not on a client site with another slow integration.
@@ -0,0 +1,18 @@
# SAFETY-118 - the peak forecast is read in its own unit
**Status: for review.** Tests: `test_safety118.py`.
`peak_forecast_entity` was read as a bare number, and anything under 50 was
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
validator also called a kW sensor wrong, which was false for this entity.
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
same unit table and reader as the solar entities, so W, kW, MW and the rest
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
not power (kWh), means the forecast is not used, exactly as if the option were
empty: the loop cap and every non-forecast peak step still apply. One WARNING
per cause, and a status row and a settings note say the same.
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
@@ -0,0 +1,99 @@
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
of 11). The rolling-budget sections below describe bee7431. Tests:
`test_safety119.py`.
**Cause.** When another owner (the peak guard, a maintenance charge) has set
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
owner's command, and `compute()` adds `gain * error` from a meter that does
not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
battery gave what it was told, and the cap held.
**Decision.** After a hand-back, the loop's integrator bound is not counted
until the plant's settle has passed (`SHORT_CYCLES` cycles and
`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
decided always counts. No command changes, and no text was added or changed.
**A rolling budget (round 3).** The settle above is per hand-back, and
losing the wire restarts it. Across all hand-backs the bound is hidden on at
most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a
cycle over the budget counts. Round 1 restarted the settle at every
hand-back, so a guard and a charge taking turns hid the bound for good (0
warnings in 2 h where 28 were raised before). Round 2 gave each run of
hand-backs its own budget; at 10 s and 15 s meters each quarter then started
a fresh one and the loop's bound was never counted. A quarter-edge kick is a
few bound cycles in one quarter, a real bound that stays is many: a budget
per 15 minutes tells them apart.
**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up
to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two
decided cycles (a stop, or a gap over two minutes, is not one); until it is
known, and without a monotonic clock, the budget is 8. Without a monotonic
clock the budget never refills.
**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the
quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the
case below. No single budget held both at 1 to 15 s: 3 to 6 let the false
alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at
2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters
at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget
to stay quiet. Per band, the largest budget that still warns: up to 3.5 s,
10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to
11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet
quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond,
4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at
20 s).
**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2,
3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W,
cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W,
constant and with noise, 8 runs per meter):
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|---|---|---|---|
| 1 s | 56 to 63 | 7 | 56 to 63 |
| 2 s | 49 to 56 | 7 | 54 to 56 |
| 5 s | 19 to 23 | 7 | 28 |
| 10 s | 7 to 10 | 7 | 14 |
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back
the false alarm. Single hand-back, the loop at its rail, first warning after
the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s
(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at
60 s. At 60 s the rig is a different regime (the house is not over the cap
in the case above), so nothing more is claimed there. The cap held at 1 to
10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower
meters the meter's lag puts quarters over it, the same with and without the
fix (commands are identical).
**Limits.** These runs have one cycle per meter reading, at a steady
interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default
15 s, a slower meter adds cycles on an old reading. Measured that way (11
seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a
30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on
none. The case above still warned every quarter there.
**Kept.** The loop in charge at its rail warns on the same cycle as before.
The start and a restart after a stop are not hand-backs. The quarter-edge
kick of `test_safety119.py` part 1 stays hidden (see above).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
claims are the same (`test_safety119.py` part 6).
**Not hidden.** A battery that gives less than another owner told it still
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
**Not changed.** The kick itself is control behaviour: for a few seconds at
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
that is more discharge than the guard asked at a quarter's end, and a few
seconds of up to 500 W more charge at the next quarter's start.
@@ -0,0 +1,95 @@
# SAFETY-121 - a recurring overload warns every quarter
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
warned at every quarter start. The loop held the wire once a quarter, at its
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
budget of 8 per 900 s.
**Why the simulation said "every quarter".** `test_safety119.py` fed the
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
5 s meter). Just after the loop leaves its rail, the guard's charge
headroom (house = meter + battery) is then off by up to 5000 W, the charge
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
now runs both shapes.
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
budget no longer follows the cycle length, and the cycle length is not
tracked. A stop ends the settle and keeps the budget. An over-rating always
counts. Commands are unchanged.
**Why it holds for any number of bound cycles a quarter.** The settle is
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
bound that lasts past it counts on its first cycle after it. So one
hand-back a quarter, with the loop at its rail past the settle, warns in
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
past a settle counts once more than 11 are hidden within 900 s. Hidden
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
the hand-back.
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|---|---|---|---|
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
Commands identical to no fix and to bee7431 in all 1013 runs (these and
the limits below). Choosing the numbers: the runs above were recorded
without the fix and replayed through candidate rules; with 3, 4 or 5
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
quarters (replay).
**First warning after one hand-back** (the loop at its rail; later than
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
1 to 30 s, the first warning was at most 90 s later than without the fix.
**Limits (measured, not fixed).**
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
later than without the fix. The numbers depend on the jitter draw: the
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
- With cycles over about 40 s, a bound that is never past a settle uses up
11 in 900 s only if the wire changes hands every few cycles; otherwise
it is never counted (part 5 prints it at 60 s).
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
in 900 s, each within 3 cycles or 22 s of a hand-back).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
bee7431 and from no fix; commands, claims, guard and phases are the same.
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
**is unchanged**, exactly as the card instructed. What changed is the floor it is
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
@@ -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,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,43 @@
# 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) outranked the peak floor (50): a 5000 W house imported
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
- 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. Fixed by [[SAFETY-84]].
@@ -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,34 @@
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
changed" list. Tests: `test_safety83.py`.
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
above the winner, so the floor was ignored and the house imported what the
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
breach starts at about 5950 W.
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
floor, still inside the safety limits, and a safety SET (100) still parks
everything. Where the floor is at or below the drain rate nothing changes.
- The order in effect is now the same at N=1 and N=2: safety limits and the
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
modes > trade > loop (`arbiter.py`, beside the priorities).
- The drain keeps counting: the battery discharges at least the drain rate on
every cycle, so the cycle is not reset or stalled; it only ends sooner.
- Below the drain rate the rest is still exported (owner, 2026-09-29).
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
cycles where the guard asked for more than the drain; after such an episode
the commands are identical again and only the log and the idle loop's
integrator differ. N=2 is unchanged.
**Not changed.**
- Between the drain rate and the peak aim, the house still imports what the
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
guard at N=1. Fixed by [[SAFETY-86]].
@@ -0,0 +1,46 @@
# SAFETY-84 - a maintenance charge at two inverters
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
**The defect.** In a cheap window the charging pack files its own
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
give while this one charged. The allocator refuses opposite directions
(SAFETY-31), so the site sat at 0 W whatever the house
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
was fine.
**The fix.**
- While a pack's own limits make it charge at N>=2, `main.py` files a site
limit `charge`, `hi` = what that pack's own `hi` places
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
never above it, so the other pack only adds power in the same direction.
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
minus `peak_margin_w`), so it charges less instead of stopping: the meter
sits on the aim and the guard does not need to step in. Where the house
alone is over the aim the guard engages, the shaping is suspended as before
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
Without the headroom sizing the aim still held (the suspension did it), but
a battery gave power back on 285 of 540 cycles at a 3000 W house
(test_safety84.py part 2).
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
pack to give. It was refused, with an ARBITER line, on most cycles of the
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
files no ceiling; the other pack follows the loop.
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
every claim, in and out of a cheap window and under a banked peak).
**Not changed.**
- At N=1 the charge is not sized to the headroom: the guard still suspends it
and the battery gives until the quarter is safe. The same sizing would fit
there; that is an owner question, not this fix.
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
comes as often at N=2 as at N=1 (2 in 480 cycles).
@@ -0,0 +1,91 @@
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
peak guard, while the battery gave exactly what it was told. Each warning
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
counter counted cycles (861), not episodes.
**Cause, measured on the real Controller.**
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
target each cycle, then `compute()` adds `gain * error` for a grid error the
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
so control comes back one slew step at a time.
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
winds to its rail for watts the pinned battery may not give. When the pin
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
**Decision.** Three rules; the alarm, its text and its id are unchanged.
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
the loop. Its number is on the wire, or only a rating (`device`,
`supervised`) cut it. No bound held it, and nothing was unmet.
(`Controller._loop_alarm_input`.)
2. **A battery another owner set to discharge alarms when it gives less than
it was told.** This covers every battery on a cycle the loop did not
decide, and a battery with its own pin otherwise. The test is more than
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
real shortfall during maintenance or under the peak guard. Before, that
shortfall was silent unless the kick happened to reach the rail (house
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
now).
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
and control comes back one slew step a cycle. Why maintenance only: that is
where the jump was measured and where the owner order puts maintenance
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
the old law. Rule 1 still silences them (the target is unmet), so they no
longer warn either.
Fixing the windup was chosen over only ignoring the wound part, because at
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
nothing to fix: re-seeding already prevents windup.
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
bound still warns (a runaway, a house above the rating), and a saturated
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
rule 2 grades only batteries another owner set.
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
bound engagement. The status row shows it ("N times since start").
`bound` (cycles) stays for the log and the tests.
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
| digest | what changed |
|---|---|
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
test_safety86's "without the ceiling the log floods" now needs the ceiling
and the bound both removed: either one alone keeps the loop inside what the
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
the 40-row log.
**Review (PR #163).** The first draft graded each battery against last
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
maintenance charge tapering near full warned 11 times in 50 min, which
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
0 in all of those cases. A battery that gives nothing still warns, lagging
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
a battery that does not take a charge another owner set is not named by this
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
`test_safety85.py` part 7.
**Not changed, open for the owner.**
- With the loop in charge and the house above the batteries' total rating,
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
count came from houses of 4500 and 6500 W.
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
the loop, with modes off. DOCS lists the value with both meanings.
@@ -0,0 +1,47 @@
# SAFETY-86 - the peak guard outranks the maintenance HOLD
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
list. Tests: `test_safety86.py`.
**The defect.** The HOLD (battery resting full after the maintenance charge)
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
**The fix** (`main.py`, `_peak_over_hold()`).
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
for the peak limit".
- N>=2: the holding battery's pin is raised to the floor minus what the other
live batteries may place (their hard `hi`, or their pin). The others give
first, the holding one only the shortfall. Why this split and not the
allocator's shares: the hold is maintenance and outranks the others' use
(owner order), and the owner rule for an Off battery is the same, "give way
only for the shortfall". A battery Off by its mode (P_MODE, below
maintenance) therefore still gives before the holding one.
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
row each cycle and filled the action log (review, PR #161).
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
are identical to before (test_safety86.py part 2).
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
never split both ways.
- The hold resumes by itself: the rule is evaluated each cycle, so when the
guard lets go the command is 0 W again.
**The level dips below the target: the hold finishes anyway.** Owner
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
when the peak guard pulls the level down; no top-up. A top-up would re-enter
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
slot meanwhile.
**Not changed.**
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
B under `peak_soc_floor` the floor is limited and A holds even when it is
full (SAFETY-87).
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
@@ -0,0 +1,83 @@
# SAFETY-87 - the peak guard uses every battery that can still give
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
changed" list. Tests: `test_safety87.py`; identity:
`tools/safety87_identity.py`.
**The defect.** At N>=2 the guard graded the emptiest battery against the
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
even with the other battery full. Measured: a maintenance charge (A at 11 %)
raised the alarm 10 times while both batteries gave 2000 W and the quarter
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
review; rig case 7a: 4563.8 W).
**The fix.**
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
then grades each battery on its own floor (`peakguard.reserve_split()`).
One at or under it is *held*. The others that are live and not refusing
(SAFETY-73) are *able*.
- A floor-limited decision is re-taken on the able batteries
(`peakguard.place_on_able()`): their own permission (min SoC against max
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
rating plus the drain a held battery gives anyway (its maintenance pin).
- The held batteries weigh 0 in the split while the site gives
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
so the holding battery covers the need.
- Floor-limited now means: some battery is held and the able ones cannot hold
the aim. The claim still asks them for `able_w`. The alarm fires only if
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
is not an alarm. With nothing able (all held, or the rest refusing or
dropped) it is the old state: 0 W, immediate alarm.
- The alarm and the row name `held`, each battery graded on its own floor. The
old code named batteries against the site's highest floor.
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
title without "Not ready", and the Home page is not shown as held at 0 W.
Rows that do stop control still outrank it.
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
max(floor) so the guard never sizes a forced discharge a battery's own BMS
stops. Now no battery at or under its own floor is asked for the peak: it is
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
The min/max pairing still decides permission, over the able batteries. The
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
call-site and reduction checks still bite. What changed is only that one held
battery no longer stops the others. One exception, with battery modes on: see
Open.
**Maintenance pins beside a held battery** (review). A draining battery that
may still give is raised from its drain rate towards its rating for the peak
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
floor's rest landed on the held battery. In a HOLD, a held battery counts its
own pin (a drain) among the others, and a held battery pinned to charge stops
the raise (never split both ways).
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
| case | before | after |
|---|---|---|
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
| both under the reserve | alarm | alarm (unchanged) |
| N=1, every scenario | | byte-identical |
**Open, for the owner.**
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
the peak guard only). It gives last, after the able ones. So with house 6500
in the charge case, B alone cannot hold the aim, the alarm fires as
specified, and the loop then lands the quarter on the aim from A. The text
says "cannot keep … alone", not "will go over", so it stays true.
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
before a held battery in the first case needs a third allocator tier. The
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
Until then, with modes on, a held battery can give for the peak.
- The notification and row text live in `messages.py`, which has no NL layer
yet (UX-19). The Home page text, which has NL, is unchanged.
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
@@ -0,0 +1,81 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -0,0 +1,74 @@
# SAFETY-91 - a held battery is drawn only after the others are full
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
its own peak reserve was held back (weight 0) only on cycles where the peak
guard asked for power.
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
near zero the loop set the target, A had its normal weight and B's drain
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
with no seconds left and asked for nothing for one cycle. The HOLD's
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
for 1 s.
**The fix** (`main.py`, N>=2 only).
- `_held_back()`: the batteries at or under their own reserve while another
can still give (live, not refusing), graded as in `_peak_on_able()`. They
weigh 0 in every split where the site gives, not only for the peak. With
nobody able (all held, or the rest refusing or dropped) nobody is held back
and the split is as before.
Review: once held back, a battery stays so until its SoC reads more than 1
point above its reserve. A reading flipping 20/21 % otherwise swapped
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
- `_drain_rise()`: while a battery is held back, a draining battery's pin
becomes a floor at its drain rate (`lo`), so it rises towards its rating
before the held one gives. Not for a held battery's own drain, not under a
site stop, not for a battery that is not live.
- `tariff.next_quarter()`: past a block's end, before the next block's first
telegram, the guard grades the next quarter, projected from the held
reading (the averager credits it there). Not when that reading is too old
to be credited (`max_hold_s`).
**What stays.** SAFETY-25 option A: once every able or draining battery is
at full power, the loop still draws a held battery (`allocator._fill()`'s
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
| case | before | after |
|---|---|---|
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
| cycles A asked while B not full | 612 of 900 | 0 |
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
| N=1, every scenario | | byte-identical |
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
extending it is a one-line change for a later ticket.
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
at its 20 % reserve, so B carries the house and A gives last (100 of 540
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
test grades that.
Two fixtures changed with it: `test_safety87.py` part 10's "without the
raise" run also turns off `_drain_rise()` (else B rises anyway), and
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
(SAFETY-87 Open). The first cycle after start, before any quarter is
measured, can still put the loop's first step on a held battery in a HOLD.
SAFETY-88, -89, -90, -92, -93, -94.
@@ -0,0 +1,60 @@
# SAFETY-93 - the maintenance charge end hands back smoothly
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
from the grid with the house at 6000 W. The HOLD end was already smooth
(SAFETY-85).
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
loop starts its next step from last cycle's site target. The slew limit
moves the site total by 400 W (-2000 -> -1600), and the only battery left
to place it on is B. The same happens when the charge times out or the
battery is switched Off (A free, its -2000 W shared out). With the charge
suspended by the guard and A giving under the loop, B took A's share in
one step (675 W).
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
battery leaves CHARGE, the loop's start point (`self.target`, and the
integrator with it) is what the other batteries stood at plus that
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
last discharge (a charge stops). The loop then moves from there one slew
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
pin, as before; it lowers the grid import. The end is kept until the loop
next runs, so a missed poll on that cycle (the input hold) does not lose it.
Review: a charge also ends, for the site, when the battery is still in
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
1200 W house). So the end is noted after the unit claims are filed: a
battery leaves the charge when it leaves CHARGE, is not live, or its own
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
Why a start-point reseed, and not a per-battery slew in the allocator: the
jump comes from one stale number at one edge, the kind of edge SAFETY-73
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
The change stays at that edge; nothing else at N>=2 moves.
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
| case | before | after |
|---|---|---|
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
| N=1, both charge ends above | | byte-identical |
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
charge end.
**Not changed.** The peak guard's floor still moves the site in one step
when it engages (a house step, a quarter edge): the guard outranks the slew
(owner order). A HOLD pin still drops the holding battery to 0 W at once
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
above the aim) is untouched.
@@ -0,0 +1,102 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
SAFETY-31's circulation check (75 s) ignores the command, so its margin
holds only while d >= 0.47.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
@@ -0,0 +1,69 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -0,0 +1,59 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
@@ -0,0 +1,118 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Owner decision 2026-10-04: the band's centre is clamped to
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Not chosen: the GoodWe LAN integration's DC battery power
Both inverters also appear through HA's GoodWe integration (B:
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
configuration, but:
- the band must be safe without it: other installs may not have it;
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
drop while the board keeps reading (HW-01 lost both once); a missing
reading stops the site;
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
integration's poll interval (believed 10 s, not checked here) and latency
are not measured;
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
It stays an install choice to test on the rig, not a code change here. With
it, the learned idle reading is simply ~0 W.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds: owner decision 2026-10-04 (review R1)
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- **Why a clamp under the band.** A wrong idle reading (taught during a
rest while the reading shows something else, e.g. a grid top-up of a unit
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
clamp the rig missed a refusing A whenever the error was 110-250 W. At
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
@@ -38,6 +38,8 @@ 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*
@@ -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).
+1069 -126
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -923,6 +923,65 @@ Cheap habits fall out of it, and all of them are now used in this repository:
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
+122 -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,99 @@ 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 hi_w(b):
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
return float(_int_envelope(b)[1])
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 +488,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 +515,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 +604,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 +620,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 +639,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 +772,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))
+221 -10
View File
@@ -76,6 +76,12 @@ from . import allocator
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
# sized to the quarter's headroom (SAFETY-84).
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
@@ -86,8 +92,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 +190,22 @@ 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,
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
# target may not go above what the charging pack's own `hi` asks, or a
# sibling would have to give against it. Never filed at N=1.
# DECISIONS/SAFETY-84-charge-at-two.md.
"charge": SCOPE_SITE,
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
# holding pack gives the peak shortfall at N>=2, the site target stays at
# the peak floor, what the pin and the others place. Never filed at N=1.
"hold": 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 +257,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 +403,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 +455,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 +487,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 +496,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 +557,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 +575,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,23 +612,30 @@ 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,
tick=None) -> Resolution:
tick=None, reserve=()) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
@@ -600,6 +681,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 +698,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 +732,132 @@ 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)
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
# gives, so the others carry the target first. An Off battery gives the
# peak's shortfall, so it goes before them when no other unit is left.
reserve = set(reserve) if out.target_w > 0.0 else set()
if held and reserve and not set(bounds) - held - reserve:
held = set()
held = held | reserve
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:
+126 -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,62 @@ 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 shortfall_note(named) -> str:
"""SAFETY-85: the alarm text while another owner sets the batteries.
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
that gave less than asked."""
return f"{REASON_CODE}: " + ", ".join(
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
f"asked" for name, asked, given in named)
def delivery_short(asked_w, given_w, margin_w) -> bool:
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
asked, given = float(asked_w), float(given_w)
return asked > 0.0 and asked - given > abs(float(margin_w))
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 +308,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 +393,80 @@ 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
# SAFETY-85: episodes with a bug=False engagement. The status row
# counts these; counting cycles read 861 on one rig run.
self.bound_episodes = 0
self._bound_counted = False
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
self._bound_counted = False
if not bug and not self._bound_counted:
self._bound_counted = True
self.bound_episodes += 1
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 +492,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 +501,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 +520,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
+8 -4
View File
@@ -221,6 +221,11 @@ function makeInput(field, value, onchange){
input.step = field.type === 'integer' ? '1' : 'any';
if(field.min !== undefined) input.min = field.min;
if(field.max !== undefined) input.max = field.max;
} else if(field.type === 'password'){
input.type = 'password';
input.autocomplete = 'new-password';
input.autocapitalize = 'none';
input.spellcheck = false;
} else {
input.type = 'text';
input.autocapitalize = 'none';
@@ -413,10 +418,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');
+52 -1
View File
@@ -206,6 +206,8 @@ def _one_field(node, translations, lang, path=()):
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("format") == "password":
field["type"] = "password"
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
@@ -289,7 +291,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:
@@ -303,6 +308,52 @@ def problems_by_field(problems, keys):
return by_key, unanchored
# --------------------------------------------------------------------------- #
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
# value replaces it. The names come from the Supervisor's own schema, so a
# password option added later is covered without an edit here.
# --------------------------------------------------------------------------- #
SECRET_SHOWN = "********"
def secret_keys(schema, extra=()):
"""Names of the options whose schema type is `password`, plus `extra`."""
# The Supervisor may spell it `type: password` or `type: string` with
# `format: password`; both count.
return frozenset(str(n.get("name")) for n in schema or ()
if isinstance(n, dict) and (
n.get("type") == "password"
or n.get("format") == "password")
) | frozenset(extra)
def hide_secrets(value, keys):
"""A copy of `value` with every secret key's value replaced by
SECRET_SHOWN (set) or "" (empty), at any depth."""
if isinstance(value, dict):
return {k: ((SECRET_SHOWN if v else "") if k in keys
else hide_secrets(v, keys))
for k, v in value.items()}
if isinstance(value, list):
return [hide_secrets(v, keys) for v in value]
return value
def keep_secrets(proposed, stored, keys):
"""`proposed` with each secret the page did not change put back from
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
out = dict(proposed)
for key in keys:
if out.get(key) in (None, "", SECRET_SHOWN):
if isinstance(stored, dict) and key in stored:
out[key] = stored[key]
elif key in out and out[key] == SECRET_SHOWN:
out[key] = ""
return out
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
+141 -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,18 @@ 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
# SAFETY-85. The `bound` held the integrator or the command this cycle,
# so the batteries' limits, not the loop, decided it.
held: bool = False
def slew_limit_w(tuning: Tuning) -> float:
@@ -436,6 +481,11 @@ def compute(
tuning: Tuning,
sat_count: int = 0,
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -443,8 +493,34 @@ 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.
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 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 +623,17 @@ 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.
held_by_bound = False
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
held_by_bound = True
# ⚠️ 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 +650,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 +662,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 +820,49 @@ 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:
# SAFETY-94: and only when the reading has given nothing for the
# whole span, not just now. `sat_count` also counts the cycles a
# healthy battery spends reversing through its lag (-2000 W towards
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
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 - idle_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
held_by_bound = True
# 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 +870,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, held_by_bound)
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
+72 -31
View File
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
@@ -216,6 +220,8 @@ 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.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
@@ -372,7 +378,9 @@ BOUNDS = {
"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"),
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -416,11 +424,9 @@ class Freshness:
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.
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
⚠️ 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
@@ -451,6 +457,11 @@ class Freshness:
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = 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
@@ -499,6 +510,7 @@ class Freshness:
# 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._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
@@ -510,6 +522,35 @@ class Freshness:
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
@@ -543,7 +584,12 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_s() < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
@@ -594,31 +640,26 @@ class Freshness:
# ("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.
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
f"CANNOT TELL THEM APART: the register may have stopped reporting "
f"reality, or the source may be unable to comply with the command "
f"- a pack the BMS is refusing holds still under any setpoint, and "
f"an honest register then reads exactly like a frozen one. Either "
f"way the number cannot be read as this command's effect, 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)")
# 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 is in the sentence
# above, the WINDOW this verdict actually rests on is here, and so is
# 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.
if self.releases:
self.reason += (
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
f"opened when the previous one was withdrawn, not when the "
f"register last moved - this unit has now been graded "
f"{self.releases + 1} times]")
# 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} 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.
+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
+150 -6
View File
@@ -11,17 +11,82 @@ import time
import aiohttp
from . import messages
_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
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
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.
SAFETY-112: the Supervisor answers 502 for every read, write and
websocket until Home Assistant has finished starting, however live its
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
So an outage also says, every STILL_WAITING_S, that it is still on, and
`waiting()` puts it on the status page.
"""
def __init__(self):
self.down_on = set()
self.since = None # monotonic start of this outage
self._told = None # when the last "still waiting" line went
def down(self, channel: str, what: str, detail) -> None:
now = time.monotonic()
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"still starting. Control waits and resumes by itself.",
what)
self.since = self._told = now
elif now - self._told >= STILL_WAITING_S:
self._told = now
_LOG.info("Still waiting for Home Assistant to finish starting, "
"%s so far.", messages.duration(now - self.since))
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 after %s.",
messages.duration(time.monotonic() - self.since))
def waiting(self) -> bool:
"""True while Home Assistant's API does not answer. The API, not the
meter websocket: every battery reading and every write rides on it."""
return "api" in self.down_on
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 +123,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 +213,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
+87
View File
@@ -0,0 +1,87 @@
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
against this idle reading, not against 0 W, or a refusing B never latches.
DECISIONS/SAFETY-97-idle-offset.md.
Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_SAMPLE_W is not a sample;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S);
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
learned the idle reading is 0 W, the rule as it was.
"""
import math
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
IDLE_TAU_S = 1800.0 # then follow drift this slowly
IDLE_SAVE_W = 5.0 # persist when the value moved this far
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
def _num(v):
try:
v = float(v)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
class IdleReading:
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
def __init__(self, saved=None):
v = _num(saved)
self.value = (None if v is None
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
self.saved = self.value
self.quiet_since = None
self.last = None
self.acc_w = 0.0
self.acc_s = 0.0
@property
def w(self) -> float:
"""The centre used: the learned value clamped to IDLE_MAX_W."""
if self.value is None:
return 0.0
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted."""
now = float(now_mono)
dt = (0.0 if self.last is None
else max(0.0, min(IDLE_GAP_S, now - self.last)))
self.last = now
standing = _num(standing_w)
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
self.quiet_since = None
return False
if self.quiet_since is None:
self.quiet_since = now
b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
return False
if self.value is None:
self.acc_w += b * dt
self.acc_s += dt
if self.acc_s < IDLE_FIRST_S:
return False
self.value = self.acc_w / self.acc_s
else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value
return True
return False
+42 -10
View File
@@ -123,9 +123,11 @@ 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
from .idle import IdleReading
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -316,6 +318,26 @@ 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.
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
self.ref_from = None
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
# is centred on it. Learned, persisted, restored (app/idle.py).
self.idle = IdleReading(data.get(self.key("idle_w")))
@property
def idle_w(self) -> float:
return self.idle.w
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -407,21 +429,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
+299 -32
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from .store import MAINT_UNIT_KEYS, unit_key
@@ -65,10 +65,30 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD)
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
# it is expected to span a night; 36 h still bounds it.
DRAIN_TIMEOUT_MIN = 720
# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not
# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN.
CHARGE_TIMEOUT_MIN = 2160
# After an abort, do not immediately re-trigger in the same start hour.
RESTART_COOLDOWN_MIN = 90
# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options.
# A pack that reaches target - 3 and then takes no more charge for this many
# minutes of allowed charging counts as full.
CHARGE_NEAR_FULL_PCT = 3
CHARGE_STALL_MIN = 60
# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun).
CHARGE_WALL_TIMEOUT_MIN = 10080
# A failed cycle (stuck drain or charge) is not retried before this many days.
FAIL_BACKOFF_DAYS = 7
# One tick counts at most this many minutes: a gap means the add-on was down.
CHARGE_TICK_MAX_MIN = 5
# The charge accounting is written to the state file at most this often.
CHARGE_SAVE_MIN = 5
# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading
# the timeouts still run, a drain holds at 0 W, and the log says once, after this
# many minutes, that the level has been unknown for long.
NOSOC_NOTE_MIN = 15
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
# OPTION, and the only place in the add-on where one of these numbers is typed.
#
@@ -112,7 +132,9 @@ RESTART_COOLDOWN_MIN = 90
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
"maintenance_start_hour": 10,
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
# into the night rather than through the solar day.
"maintenance_start_hour": 19,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
@@ -177,6 +199,69 @@ class MaintConfig:
)
# --------------------------------------------------------------------------- #
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
# --------------------------------------------------------------------------- #
def _load_zone(name):
"""A tzinfo for an IANA name, or `(None, why)`."""
if not name or not isinstance(name, str):
return None, "none given"
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name.strip()), ""
except Exception as err: # noqa: BLE001 - any failure
return None, f"{name!r} is not a known zone ({type(err).__name__})"
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
tz, why = _load_zone(ha_zone)
if tz is not None:
return tz, ha_zone, "ha", ""
ha_why = ha_why or why
tz, env_why = _load_zone(env_tz)
if tz is not None:
return tz, env_tz, "tz", ha_why
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
"""Is `now` inside today's one-hour start slot, read on the local clock?
Normally the slot is the local hour `start_hour`. On the day the clocks go
forward and that hour does not exist, it is the first hour after the jump.
On the day they go back and it happens twice, only the first one counts.
Worked from UTC instants only (`astimezone`), so it does not depend on how
a tzinfo resolves a wall time that is missing or doubled.
"""
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
here = now.astimezone(tz).replace(tzinfo=None)
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
if here.hour == start_hour:
# the second pass through a repeated hour: an hour ago read the same
return not (before.date() == here.date() and before.hour == start_hour)
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# the clock jumped over the start of the slot within the last hour
return before < slot and here >= slot + timedelta(hours=1)
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
"""The start of the slot `now` is in, or else of the next one; None if
there is none within 50 h (no real zone does that)."""
step = timedelta(minutes=15)
t = now.replace(minute=now.minute - now.minute % 15, second=0,
microsecond=0) - timedelta(hours=1)
for _ in range(4 * 50):
if (in_start_slot(t, start_hour, tz)
and not in_start_slot(t - step, start_hour, tz)
and t + timedelta(hours=1) > now):
return t
t += step
return None
@dataclass
class MaintResult:
phase: str
@@ -192,6 +277,9 @@ class MaintResult:
# showing an idle machine and a due date in the past. Empty on every path
# that is not deferring, so a roster of one never sees it.
deferred: str = ""
# MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so
# the Controller can tell the operator once. Empty on every other tick.
failed: str = ""
class UnitHistory:
@@ -256,8 +344,12 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
self.cfg = cfg
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
# once at startup (`site_zone()`). UTC when nobody says, as before.
self.tz = tz or timezone.utc
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
@@ -275,6 +367,14 @@ class Maintenance:
# per cycle would flush the very context that explains it. Same pattern
# as `Controller._arb_action` and `_maint_shape_suspended`.
self._deferred = ""
# MAINT-13: the charge accounting (see _charge_account), in memory and
# written to the state file every CHARGE_SAVE_MIN minutes.
self._trk = None
self._trk_saved = None
# MAINT-14: when the SoC reading went missing (None while it is there),
# and whether the "unknown for long" line was said in this episode.
self._nosoc_since = None
self._nosoc_said = False
# -- state ---------------------------------------------------------------
@property
@@ -282,6 +382,11 @@ class Maintenance:
return self.store.get("phase", IDLE)
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
self._nosoc_since = None
self._trk = None
self._trk_saved = None
if self.store.get("charge_track") is not None:
self.store.set("charge_track", None)
self.store.set("phase", phase)
self.store.set_time("phase_started", now)
_LOG.info("%s", msg)
@@ -322,6 +427,21 @@ class Maintenance:
return (last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
def backoff_until(self, now: datetime):
"""MAINT-13: when a failed cycle may be tried again, or None.
None when nothing failed, when the back-off is over, or when a cycle
completed after the failure (a forced start that worked ends it).
"""
failed = self.store.get_time("last_failed")
if failed is None:
return None
done = self.store.get_time("last_completed")
if done is not None and done >= failed:
return None
until = failed + timedelta(days=FAIL_BACKOFF_DAYS)
return until if now < until else None
def wants_start(self, now: datetime) -> bool:
"""Would this pack begin a cycle on this tick if the site let it?
@@ -331,8 +451,10 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and self._cooled(now))
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and self._cooled(now)
and self.backoff_until(now) is None)
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime, blocked_by: str = "") -> list:
@@ -351,7 +473,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]
@@ -360,6 +482,140 @@ class Maintenance:
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
return events
def _charge_account(self, now: datetime, soc: float | None,
gate_open: bool) -> dict:
"""MAINT-13: what this charge has used so far, as minutes of ALLOWED time.
`open_min` counts only ticks with the price gate open; `flat_min` counts
gate-open minutes since the SoC last beat its best; a closed gate pauses
both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down)
counts for nothing. A state file from before MAINT-13, or from another
charge, starts from the wall-clock minutes so far: the old behaviour.
MAINT-14: `soc` may be None (no reading). Time still counts, the stall
wait pauses, and `last` keeps the last SoC seen (saved with the rest).
"""
since = self.store.get("phase_started")
trk = self._trk
if trk is None:
raw = self.store.get("charge_track")
if isinstance(raw, dict) and raw.get("since") == since:
trk = dict(raw)
else:
trk = {"since": since, "open_min": self._elapsed_min(now),
"flat_min": 0.0,
"best": None if soc is None else float(soc),
"last": None if soc is None else float(soc), "at": None}
self._trk = trk
at = trk.get("at")
try:
last = datetime.fromisoformat(at) if at else None
except (TypeError, ValueError):
last = None
dt = 0.0 if last is None else min(
CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0))
trk["at"] = now.isoformat()
if soc is None:
pass
elif trk.get("best") is None or soc > trk["best"]:
trk["best"], trk["flat_min"] = float(soc), 0.0
elif gate_open:
trk["flat_min"] += dt
if soc is not None:
trk["last"] = float(soc)
if gate_open:
trk["open_min"] += dt
saved = self._trk_saved
if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60:
self.store.set("charge_track", dict(trk))
self._trk_saved = now
return trk
def _fail(self, now: datetime, kind: str, why: str) -> MaintResult:
"""MAINT-13: stand down, remember the failure, and tell the Controller."""
events = self.abort(now, why)
self.store.set_time("last_failed", now)
msg = (f"maintenance: the cycle did not finish. It waits "
f"{FAIL_BACKOFF_DAYS} days before it tries again.")
_LOG.warning("%s", msg)
events.append(msg)
return MaintResult(IDLE, True, 0.0, False, events, failed=kind)
def _hold(self, now: datetime, events: list) -> MaintResult:
"""HOLD for one tick, with or without a SoC reading (MAINT-15).
HOLD needs no level to end: it is reached only after the charge counted
as done (MAINT-13/14), so its end is a time check and nothing else.
"""
elapsed = self._elapsed_min(now)
if elapsed >= self.cfg.hold_min:
self.store.set_time("last_completed", now)
self._enter(IDLE, now, events,
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
"- handing back to the loop")
return MaintResult(IDLE, False, None, False, events)
# 0 W, not "do nothing": commanding zero is what stops the loop
# pulling the pack straight back down the moment it is full, which
# is exactly when the BMS needs it parked at the top.
return MaintResult(HOLD, True, 0.0, False, events)
def _no_soc(self, now: datetime, phase: str, gate_open: bool,
events: list) -> MaintResult:
"""MAINT-14: one tick of a pack in a moving phase with no SoC reading.
HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h
timeout still runs. CHARGE keeps its timeout and wall backstop: out of
time is a failure, except when the last SoC seen was within
CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13).
Nothing here may start a phase change that needs the level.
"""
if self._nosoc_since is None:
self._nosoc_since = now
self._nosoc_said = False
self._trk_saved = None # save the last SoC seen, now
if phase == DRAIN:
msg = ("maintenance: the battery level is unknown, so the "
"drain holds at 0 W until it is back.")
_LOG.warning("%s", msg)
events.append(msg)
if (not self._nosoc_said and (now - self._nosoc_since).total_seconds()
>= NOSOC_NOTE_MIN * 60):
self._nosoc_said = True
tail = ("The hold still ends at its normal time."
if phase == HOLD else
f"The time limit of the {phase} still applies.")
msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. "
f"{tail}")
_LOG.warning("%s", msg)
events.append(msg)
if phase == HOLD:
return self._hold(now, events)
elapsed = self._elapsed_min(now)
if phase == DRAIN:
if elapsed > DRAIN_TIMEOUT_MIN:
return self._fail(now, "drain",
f"drain stuck, battery level unknown, after "
f"{elapsed / 60:.0f} h")
return MaintResult(DRAIN, True, 0.0, False, events)
trk = self._charge_account(now, None, gate_open)
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
last = trk.get("last")
if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT:
self._enter(HOLD, now, events,
f"maintenance: charge time is up and the last level "
f"seen was {last:.0f} %, close enough to full, so the "
f"charge counts as done -> holding "
f"{self.cfg.hold_min} min to balance")
return MaintResult(HOLD, True, 0.0, False, events)
if elapsed > CHARGE_WALL_TIMEOUT_MIN:
lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit"
else:
lim = f"{trk['open_min'] / 60:.0f} h of charging time"
return self._fail(now, "charge",
f"charge stuck, battery level unknown, after {lim}")
return MaintResult(CHARGE, False, None, True, events)
def abort(self, now: datetime, why: str) -> list:
events = []
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
@@ -378,23 +634,25 @@ 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
# -- the machine ---------------------------------------------------------
def tick(self, now: datetime, soc: float | None,
blocked_by: str = "") -> MaintResult:
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
"""One cycle of this pack's state machine.
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
site's one maintenance slot is available to it, otherwise the reason it
is not. It gates two things and nothing else - a start, and a phase that
is already moving power on a pack that does not hold the slot.
`gate_open` (MAINT-13) is whether the pack may charge now (the price
gate); only those minutes count towards the charge timeout.
"""
events: list = []
phase = self.phase
@@ -445,13 +703,13 @@ class Maintenance:
# transitions below mean anything, and a stale "100 %" would end the
# charge phase having charged nothing. Hold the current phase's command
# and wait; the phase timeout is the backstop.
#
# MAINT-14: "hold and wait" is not "wait for ever". The timeouts run
# here too, and a drain holds at 0 W (an unknown level never allows
# discharge, TEL-08). See `_no_soc`.
if soc is None:
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
if phase == DRAIN:
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if phase == HOLD:
return MaintResult(HOLD, True, 0.0, False, events)
return MaintResult(CHARGE, False, None, True, events)
return self._no_soc(now, phase, gate_open, events)
self._nosoc_since = None
elapsed = self._elapsed_min(now)
@@ -462,8 +720,8 @@ class Maintenance:
f"after {elapsed:.0f} min -> charge")
return MaintResult(CHARGE, False, None, True, events)
if elapsed > DRAIN_TIMEOUT_MIN:
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
return MaintResult(IDLE, True, 0.0, False, events)
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
f"after {elapsed:.0f} min")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if phase == CHARGE:
@@ -472,22 +730,31 @@ class Maintenance:
f"maintenance: charge complete at {soc:.0f} % after "
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
return MaintResult(HOLD, True, 0.0, False, events)
if elapsed > CHARGE_TIMEOUT_MIN:
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
return MaintResult(IDLE, True, 0.0, False, events)
trk = self._charge_account(now, soc, gate_open)
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
# MAINT-13 rule 1: close to full and no longer taking charge.
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
self._enter(HOLD, now, events,
f"maintenance: the battery takes no more charge at "
f"{soc:.0f} %, so the charge counts as done -> holding "
f"{self.cfg.hold_min} min to balance")
return MaintResult(HOLD, True, 0.0, False, events)
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
# MAINT-13 rule 2: out of time close to full is complete.
if near:
self._enter(HOLD, now, events,
f"maintenance: charge time is up at {soc:.0f} %, "
f"close enough to full, so the charge counts as "
f"done -> holding {self.cfg.hold_min} min to balance")
return MaintResult(HOLD, True, 0.0, False, events)
return self._fail(now, "charge",
f"charge stuck at {soc:.0f} % after "
f"{trk['open_min']:.0f} min of charging time")
return MaintResult(CHARGE, False, None, True, events)
if phase == HOLD:
if elapsed >= self.cfg.hold_min:
self.store.set_time("last_completed", now)
self._enter(IDLE, now, events,
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
"- handing back to the loop")
return MaintResult(IDLE, False, None, False, events)
# 0 W, not "do nothing": commanding zero is what stops the loop
# pulling the pack straight back down the moment it is full, which
# is exactly when the BMS needs it parked at the top.
return MaintResult(HOLD, True, 0.0, False, events)
return self._hold(now, events)
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
events += self.abort(now, f"unknown phase {phase!r}")
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
"""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, test_price_run_fixes.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 a measured 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 3.5 kW);
* 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. The one exception
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
card's "tries again every 15 minutes" holds in the run too;
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
The rest of the ask is what the other chargers would have had without
the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests;
* 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 .control import PROBE_W
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
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
@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
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
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, probe=False) -> list:
"""Batteries that may charge from the grid in a cheap run: below the
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
pin keeps it at PROBE_W (OPT-15)."""
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 or (probe and p.probing))
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, probe=True) \
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:
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
# there); only the batteries that charge without a probe are asked
# for their rating.
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
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:
room = float(headroom_w) if headroom_w is not None else 0.0
pw = float(tick.probe_w)
if pw > 0.0:
# OPT-20: the others' share is sized as without the probe; the
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
# most), and alone it is sized by the bound and the headroom.
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
room))
if rest > 0.0:
return -(rest + pw)
# PROBE_W exactly or no trade: less would void the pin and hand
# the remainder to whichever battery the allocator can reach.
return -pw if min(-float(lo), room) >= pw else 0.0
cap = min(-float(tick.ask_w), -float(lo), room)
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
+204 -17
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
@@ -27,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"manufacturer": "GoodWe (via RS485 EMS port)",
"model": "ES/BP series",
}
@@ -169,8 +171,28 @@ def optional_keys(opts: dict) -> tuple:
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.
"""
return ("pv",) if str(opts.get("pv_entity") or "").strip() else ()
# ⚠️ 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"
@@ -355,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device,
return cfg
def discovery_payloads(units=(), omit=(), include=()) -> 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
@@ -369,6 +410,8 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
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]
@@ -380,19 +423,10 @@ def discovery_payloads(units=(), omit=(), include=()) -> 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,
@@ -407,12 +441,135 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
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=(), include=()):
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
@@ -428,6 +585,10 @@ class MqttPublisher:
# 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:
@@ -450,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()
@@ -465,10 +627,35 @@ class MqttPublisher:
# 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,
self.include):
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
+67 -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)
@@ -306,6 +312,19 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_t(self) -> float | None:
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
with `roll=True` credits it into the next block as `add()` will."""
return self._last_t
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
@@ -467,7 +486,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 +538,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 +598,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 +611,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 +647,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 +1194,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 +1232,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 +1298,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
+223 -47
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
@@ -179,6 +183,8 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -393,35 +399,36 @@ function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
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 +459,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,29 +486,49 @@ 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
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
# `able_w`, all they have, when that is not enough (`place_on_able()`).
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
# which give nothing for the peak, and what the others can give (None
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
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 +544,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,11 +595,63 @@ 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
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
@@ -699,8 +793,45 @@ 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 reserve_split(packs, soc_floor) -> tuple[list, tuple]:
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
each pack graded on its own floor by `pack_permissions()`."""
perms = pack_permissions(packs, soc_floor)
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
tuple(n for n, why in perms if why is not None))
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
describe the others, as `decide()`'s arguments do for the roster. A
floor-limited decision is re-taken on them: they are asked for the need
up to `able_w`, and it stays floor-limited only if that is not enough.
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
held = tuple(held)
if not decision.floor_limited or decision.wanted_w is None \
or discharge_permitted(soc, soc_floor) is not None:
return replace(decision, held=held)
wanted = float(decision.wanted_w)
floor = min(wanted, float(able_w))
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
f"against a {decision.ceiling_w:.0f} W limit "
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
f"At the peak reserve, so giving nothing for the peak: "
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
if floor >= wanted:
return replace(decision, floor_limited=False, floor_w=floor,
soc_floor_reason=None, held=held, able_w=float(able_w),
reason=f"PEAK_GUARD: {head}")
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
f"give, but {wanted:+.0f} W was needed, so this "
f"period may go over the limit"))
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 +849,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 +865,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 +877,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 +885,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 +909,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 +928,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:
@@ -1019,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None:
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
and not decision.floor_w
else "quarter peak"),
actuator=ACTUATOR_SITE)

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