Compare commits

...
Author SHA1 Message Date
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
192 changed files with 40656 additions and 1029 deletions
+1 -1
View File
@@ -1,2 +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").
ecd3864d9376a990c267dfb399eb2dae4e56c76b
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
+2 -1
View File
@@ -7,7 +7,8 @@ This applies to everything a home owner or installer reads: notifications, phone
- **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.
- **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its own codes, because that is the evidence an installer searches for.
- **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.
+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

+251
View File
@@ -1,5 +1,256 @@
# Changelog
## 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
@@ -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,186 @@
# 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).
## 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,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.
@@ -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,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,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).
@@ -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.
@@ -61,3 +61,32 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line
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).
+337 -42
View File
@@ -183,6 +183,12 @@ The label and description each setting shows on the Configuration tab, in tab or
| Hardware · Inverter list | Battery power sign is reversed (optional) | `inverters.batt_invert` | Switch this on if this inverter's sensor shows giving power as a negative number. |
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. |
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
@@ -203,6 +209,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
@@ -225,7 +232,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
@@ -273,6 +280,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
- **Margin below the peak limit** (`peak_margin_w`): 50 W by default, 0 to 500 W (SAFETY-79). Peak limiting aims at the peak limit minus this margin, because a 15-minute period aimed exactly at the limit can end a watt or two over it (3501.9 W against 3500 W in a test), and even 1 W over sets the billed peak. Once this month's peak is already above the limit, it aims the same margin below that peak. The bill, this month's peak and the warnings still compare against the peak limit itself. 0 aims at the limit, as before.
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
@@ -295,7 +303,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported, and neither is one that gives under 100 W (SAFETY-73: it is a battery that refuses). Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
@@ -512,6 +520,54 @@ two of a thing there is one of, and `P1 sample age` is a safety input. Same for
status, the maintenance phase, the capacity-tariff peak and the three loop rows
below.
### Battery modes (`battery_modes_enabled`)
Off by default. Turn it on, restart the add-on, and the Batteries screen (and Settings, Simple) gives each battery two controls. They apply at once, with no restart.
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
- **Mode.**
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
- A carried (not yet published) price is never used. A run that has started runs to its end.
- With no prices, or too small a gap, every battery follows the house.
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
**What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it.
- While one battery charges from the grid, the others rest, and the house uses the grid.
- While one battery empties for its monthly cycle, no battery charges from the grid.
- A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
*For installers:* commands arrive on `goodwe_ctl/unit/<battery>/mode/set` (a *Mode* option, such as `Solar first`) and `.../use/set` (`ON` or `OFF`). They go through the same checks as the Batteries screen. A refusal is logged once, and later refusals are counted in the log. A retained command is never applied, so the broker cannot replay an old choice after a restart; the add-on keeps the last choice itself.
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
The control law is a bounded integrator: an accumulator that is **not** the
@@ -526,7 +582,7 @@ accumulator is doing:
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
| `Loop reason` | — | the law's own word for this cycle |
`Loop reason` takes one of eight values:
`Loop reason` takes one of eleven values:
| value | what happened |
|---|---|
@@ -537,7 +593,45 @@ accumulator is doing:
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
whatever it is asked. The add-on then asks 0 W instead of repeating its last
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request. Nor is a battery whose reading is
still moving towards the request, such as one turning from charging to
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
at rest, which the add-on learns after two quiet minutes and keeps: some
inverters show about -100 W at rest (SAFETY-97).
While another task sets the power, such as a maintenance charge, the peak
guard or a trade, the refusal stays as long as the battery still refuses that
task's request. It is logged once, and the 200 W test waits until control sets
the power again (SAFETY-107).
**With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
asked for more than 200 W is asked 0 W, and the other inverters take over its share, each up to
its own limits. The status page names it: *inverter-a: the battery refuses to
give power, the others take over*. It is tested at 200 W every 15 minutes, one
inverter at a time, and only while the site asks at least 200 W that way.
It still accepts charge, and 12 cycles of charging end the refusal. When every
inverter refuses the same way, the whole site asks 0 W as with one inverter.
If the battery refuses to give power while its level reads above its “Battery
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
A battery that refuses is not reported as *not delivering*.
*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
N>1), and the INFO line ends in `BATTERY_REFUSES`.
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
`DECISIONS/SAFETY-73-refusal-per-inverter.md` the two-inverter ones.
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
@@ -1558,19 +1652,52 @@ help - see "Why the tuning is what it is".
|---|---|---|
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
**The start hour is local time.** The add-on reads Home Assistant's time zone
(Settings → System → General) at startup. If Home Assistant does not answer,
it uses the add-on's own time zone, and failing that UTC. The log says which
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. On the day the clocks go forward, a start hour that does not exist
starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on
a later day.
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
saved hour, and that hour now means local time: 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. If you set an earlier hour to get an evening start in UTC (for
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
**When the charge counts as done.** A battery may stop at 98 or 99 % while it
balances, so it need not reach `maintenance_soc_target`. The charge counts as
done when the battery is within 3 points of the target and has taken no more
charge for 60 minutes of charging time, or when the charging time (36 hours) runs
out within 3 points of the target. Only the minutes the cheap-hours gate let the
battery charge count, and the charge ends after 7 days at the
latest. Then the usual hold follows.
**When a cycle fails.** If the battery does not empty in 12 hours, or does not
get within 3 points of the target in 36 hours of charging time, the cycle
stops, you get the notification *Battery cycle did not finish*, and it is not
tried again for 7 days. The status page shows *Maintenance paused* with the
date. Starting a cycle by hand ignores the wait. *For installers:*
DECISIONS/MAINT-13-full-pack-is-complete.md.
### Tariff (all optional)
| option | meaning |
|---|---|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
@@ -1580,6 +1707,30 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
is dropped so the battery can shave the peak instead. Money outranks the
maintenance schedule.
With two or more batteries, the one being emptied gives its drain rate and the
others only add power when the house needs more; none charges while it drains
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
If the house would still go over the peak limit, the battery being emptied
gives more than its drain rate, with one battery too (SAFETY-83).
A battery at its peak reserve gives last: the others give their full power
first, and the battery being emptied rises above its drain rate before it
(SAFETY-91).
In a cheap window the battery being filled charges and the others only add
charge; none gives while it charges. The charge is cut to what keeps the
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
While a full battery rests after its charge (the hold), it gives just enough
to keep the 15-minute peak at that aim if the house needs it; with two
batteries the other one gives first. The hold still ends on its clock
(SAFETY-86).
When a charge ends, the other batteries take over one slew step a cycle,
and none starts charging from the grid (SAFETY-93).
When a battery's link drops, the others take over one slew step a cycle,
and control does not stall on the dropped battery's power (SAFETY-96).
When a drain ends (done, Off, timeout, abort, lost link, refused), the
other batteries cover the house at once but do not export its drain; then
they move one slew step a cycle (SAFETY-95). While that battery settles,
an old battery reading does not move them (SAFETY-103).
#### Capacity-tariff exposure of the grid-following loop
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
@@ -1639,7 +1790,7 @@ is the design rather than an accident: it is 1.0 at the top of the quarter and
grows, so the guard is gentlest when its projection rests on the least evidence
and becomes aggressive only once the quarter is nearly measured.
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
**The ceiling** is `peak_cap_w`, raised to this month's recorded peak when that
already stands higher — once a month's bill is set at 5013 W, holding later
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
@@ -1648,10 +1799,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
@@ -1672,7 +1830,8 @@ control action and goes to the action log and the status page. A *saturated*
guard — asking for everything the inverter has while the quarter is still
projected over — raises one notification (`goodwe_peak_saturated`), because that
is the one state where the money will be spent and shedding load is the only
thing left that changes the bill.
thing left that changes the bill. SAFETY-81 keeps a 1 s meter spike, noise and
a quarter's last seconds from raising it: see `peakguard.load_too_high()`.
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
The same arithmetic read the other way answers *"how hard may this site charge
@@ -1713,10 +1872,12 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
It will discharge straight past this number and keep going until the pack's own
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening.
**nothing more**. See below for what that costs on a real evening. With two
or more batteries, one under it is used last: only once the others give
their full power (SAFETY-91).
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
peak reserve* (`PEAK GUARD FLOOR-LIMITED` in the log): the quarter **will**
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
go over the ceiling, so shedding load is
the only thing left that changes the bill. Failing to shave is never silent —
the euro figure is the whole point of the guard.
@@ -1758,7 +1919,7 @@ because the guard it bounds is not the thing doing the discharging.
**And the two can happen in the same cycle.** On the high-load block of the same
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
`PEAK_GUARD_FLOOR_LIMITED` — *"there is no forced discharge"* — on 49 cycles,
and on **all 49 of them** the loop is simultaneously being granted a discharge of
up to 2000 W by the arbiter. The status page is telling you the truth about the
guard while the battery empties.
@@ -1848,16 +2009,25 @@ Both are watched. A guard that watched only the emitted value would be silent on
every standard configuration.
**The integrator bound is not a bug, and it is reported as what it is.** It
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
notice still on screen. Brief
engagements after a large load or solar step are normal. If it stays engaged,
check that the battery is following its commands. Only a command over the rating
carries the BUG SIGNAL text, and it has its own notification budget.
reads *"the correction the add-on builds up reached its +5000 W limit,
command now +2000 W"*, titled *"Battery is not keeping up"*, logged
as a `WARNING`, with an amber (not red) status row and its own notification
(`goodwe_integrator_bound`), so it never replaces a software-fault notice still
on screen. Brief engagements after a large load or solar step are normal. If it
stays engaged, check that the battery is following its commands. Only a command
over the rating is logged as a software fault, at `ERROR`, and it has its own
notification budget.
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
The integrator bound counts only on a cycle the loop decided. While
maintenance, the peak guard or a safety limit sets a battery to discharge, the
same alarm fires only when that battery gives more than `saturation_w` less
than the least it was told over the plant's settle time (at least 23 s), for
`saturation_cycles` cycles. A charge is not graded: a full battery takes less
as it tops up. At two or more inverters the loop does not wind while
maintenance holds a battery.
Why: [`DECISIONS/SAFETY-85-no-false-not-keeping-up.md`](DECISIONS/SAFETY-85-no-false-not-keeping-up.md).
### The frozen-meter alarm
A meter that has died is easy — it stops answering, the sample age climbs past
@@ -2444,7 +2614,7 @@ since TARIFF-11 that row carries the provider plan itself:
```
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
day-ahead prices from the price feed, 60 prices over 33 hours,
36 published and 24 estimated, 900+3600 s apart, not final yet
36 published and 24 estimated, one every 15 minutes or every hour
```
⚠️ **The two halves of that row are not computed at the same moment, and the
@@ -2461,9 +2631,10 @@ Read the plan half as five separate facts: which *product* the feed publishes
and whether it came from the feed or from your fixed tariff, how many
**prices** it carries and how many **whole hours** they cover, how many are
**published** (firm, for their own interval) against **estimated** (carried
from yesterday under §8.3), how far **apart** they are in seconds — `900+3600`
is the normal mixed steady state, quarter-hourly firm points beside hourly
carried ones — and `not final yet` (provisional) whenever any is estimated.
from yesterday under §8.3), and how far **apart** they are — "one every 15
minutes or every hour" is the normal mixed steady state, quarter-hourly firm
points beside hourly carried ones. The plan is provisional exactly when some
prices are estimated, so the estimated count says it.
⚠️ **Points and hours are two different numbers and this row used to print one
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
@@ -2856,6 +3027,12 @@ works under hourly pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
On the add-on page, choose **Other time windows** under Settings, Tariff and
prices, or in Setup step 4. Add one row per time window, with its start, end,
days and price. The page names any overlap, gap or empty price before you can
save. You only need the JSON below for days the page cannot choose, such as
Monday and Wednesday only.
It is one option, holding a JSON object, on one line:
```json
@@ -3020,7 +3197,10 @@ The battery gives less power than the add-on asks for. This is normal for a
short time, for example just after a large load switches on or the sun comes
out. It is not a fault, and control keeps running. If it keeps coming back,
check that the battery is switched on, and that it is not empty (when it should
give power) or full (when it should charge).
give power) or full (when it should charge). During the monthly maintenance or
while the peak limit is guarded, it only shows when the battery gives
clearly less than it was told. The status page counts how many times it
happened.
### Power request too high
@@ -3055,6 +3235,10 @@ and what to do:
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
for each inverter in the settings.
With several inverters, a battery at its peak reserve does not give for the
peak and the others do. This alarm then comes only when the others cannot
keep the period under the limit alone. Control keeps running meanwhile.
The peak reserve only applies to keeping the peak down. Normal home use can
still draw the battery below it, down to the battery cut-out level, where the
battery switches itself off.
@@ -3090,12 +3274,23 @@ reading is stuck. Control keeps running and keeps following the reading, so a
stuck reading can raise this month's peak. Check the meter. If it is stuck,
stop control on the add-on page.
### Battery cycle did not finish
The monthly battery cycle stopped because the battery did not empty, or did not
fill up, in time. The battery is back under normal control. The add-on waits 7
days before it tries again, so it does not empty the battery every night. To
try sooner, start a cycle on the add-on page. If it keeps happening, check that
the battery follows its commands and that the cheap hours leave time to charge.
### For installers: notifications
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The full figures and reason codes are on the ERROR or
WARNING line of the add-on log.
replaces the card. The ERROR or WARNING line of the add-on log names the
reason code. The full figures are in `events` in the `status` JSON, also for a
stop restored after a restart, and on the DEBUG line logged right after it.
That DEBUG line only shows with “Log level” (log_level) set to `debug`; the
default `info` hides it.
| Title | Severity | id | Card |
|---|---|---|---|
@@ -3106,6 +3301,7 @@ WARNING line of the add-on log.
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
@@ -3118,8 +3314,9 @@ section without a link. The documentation tab adds no heading anchors, so the
link cannot jump to the section.
**Battery is not keeping up.** The grid-following loop's integrator reached its
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
anti-windup bound (`integrator_max_w`, SAFETY-40), or a battery that another
owner set to discharge gave less than it was told (SAFETY-85). No command went
over the rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
winding-loop alarm*.
**Power request too high.** A computed command above a limit was cut before it
@@ -3134,9 +3331,12 @@ reason code `OUTPUT_CLAMP`.
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
everything the inverter can discharge and the running 15-minute average is
still projected over the peak limit, so the month's billed peak
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
plant that did not respond (SAFETY-47), and the text says so. See *The
capacity-peak guard*.
(capaciteitstarief) goes up. It fires only when, for 10 s, the period would
still end more than 10 W over the real limit (not the margin's aim) with the
battery at full power. It notifies at most once per period; later overloads
in the same period go to the log (SAFETY-81). In shadow mode the
guard's projection is made on a plant that did not respond (SAFETY-47), and
the text says so. See *The capacity-peak guard*.
**Battery stopped limiting the peak.** The peak guard may not discharge below
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
@@ -3144,7 +3344,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label
“Battery cut-out level”) is missing, because the reserve is graded against it.
Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. ⚠️ **The reserve binds the peak guard only
its own cut-out level. That battery is held (weight 0 while the site gives)
and the guard's need goes to the others, up to their rating; the alarm fires
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
@@ -3188,6 +3390,80 @@ action log (`Controller.events`). The page no longer shows that log, but the
page and add `status` to its address. The action-log tables in this document
refer to it.*
### Language and screenshots
The page is in Dutch when your browser's language is Dutch, and in English
otherwise. Status lines, alarms and the start checks' messages stay in English.
Screenshots of each screen are in the add-on's repository, under
`docs/img/ux13/`, including the Batteries screen and Settings, Simple.
*For installers: regenerate the screenshots with
`tools/ux13_screenshots.sh`, which serves the page from test fixtures.*
### The Batteries screen and Settings, Simple
The **Batteries** tab has a card per battery: its level, and three lines.
**Now** says what it does, **Today** lists the blocks planned for it (price
trades, the monthly cycle, the peak limit), and **Why** says the reason. With
battery modes on, each card also has the On/Off switch and the mode list; both
apply at once. Today reads "Not planned yet" until the add-on has made the
day's price runs.
An Off battery that the peak limit still needs says so, with the power it
gives: "Off, but gives 0.5 kW for the peak limit."
With two or more batteries, each card also has **Start cycle** and **Stop
cycle**, to run the monthly cycle on that battery now. Only one battery cycles
at a time: a start while another battery is cycling, or on a battery that is
switched off, is refused and the message names the battery.
**Settings** opens on the **Simple** view, or on the view you last picked:
the peak limit, the batteries' switches and modes, and shadow mode, each
marked "Applies right away" or "Applies after a restart". **Installer** shows
every setting.
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
inverters and batteries, solar (optional), prices, the peak limit, and a last
check. Each step shows the same fields as Settings, and nothing is saved until
the last step.
- **The kettle test** on the meter step reads the meter, then reads it again
after you switch on a kettle. If the reading goes down, the add-on flips its
sign for you; with a net or import/export meter it tells you what to change.
- **The last step** runs the add-on's own start checks and reads Home
Assistant: the meter reading, that each power request entity exists, the
battery levels, and whether prices are known. A missing power request or
meter entity is red, so it cannot be saved.
- **Save and restart** saves, then restarts the add-on. Every inverter is set
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
stays at 0 W until you turn shadow mode off under Settings.
Setup opens by itself on a new install's first start, until Setup finishes or
control is started, and whenever the stored settings name no inverter to
control. A site that already ran before is never sent there.
A new install names no inverter yet, so the add-on starts **in setup**: it
serves the page and sends nothing to any inverter. Home says "Setup: nothing
is controlled yet", and there is no Start button. The installer's steps:
1. Install the add-on, turn on **Watchdog** on its Info tab, and start it.
Leave the Configuration tab as it is.
2. Open the add-on's page. Setup opens by itself.
3. Go through the six steps and press **Save and restart**. With "Start in
shadow mode" on, every inverter stays at 0 W after the restart.
4. On Home, check the meter direction and what the add-on would do. Turn
shadow mode off under Settings when it looks right.
Any other problem in the stored settings still stops the add-on (exit code 78),
and the log names each one. Fix those on the Configuration tab.
*For installers: cards UX-13 and UX-15, `DECISIONS/UX-13-setup-wizard.md` and
`DECISIONS/UX-15-setup-only-mode.md`. In setup the `status` JSON has
`setup_only: true`, and no MQTT entity is published.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
@@ -3201,15 +3477,20 @@ inverter stays at 0 W. If the banner is red and says a safety stop stays on,
one of the red rows below is a stop that will still be there when shadow mode
ends. That row says how to clear it.
In **setup** (a new install that names no inverter yet) the banner says
nothing is controlled yet, and the one row says to finish Setup. See *Setup,
for a new install*.
| Row starts with | What it tells you |
|---|---|
| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. |
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
@@ -3222,8 +3503,9 @@ ends. That row says how to clear it.
### For installers: status page
The texts are in `app/messages.py`. The full figures of any alarm are on its
ERROR or WARNING line in the add-on log.
The texts are in `app/messages.py`. An alarm's ERROR or WARNING line names its
reason code, and its full figures are on the DEBUG line right after it and in
`events` in the `status` JSON.
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
@@ -3244,16 +3526,29 @@ ERROR or WARNING line in the add-on log.
**Check for inverters working against each other** is SAFETY-31, **Delivery
check** SAFETY-42.
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
TARIFF-13. The store's state at start is the INFO line `capacity-tariff peak
store: …`, and the peak guard's full reason is on its log line.
TARIFF-13. The store's state at start is the INFO line `Month peak store:
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line carries the full `STALE_READING` reason and when it clears.
ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
line names the missing reading.
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
history**, **Arbitrage** and **Shadow mode** keep every figure they had
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers
502 to every add-on read, write and websocket until Home Assistant is
RUNNING, even when its entities are already live. A slow integration at
start-up therefore holds control for as long as it holds Home Assistant's
start. The log has one WARNING, a "Still waiting" INFO line every 5
minutes and "answers again after …" at the end. See
`DECISIONS/SAFETY-112-waiting-for-home-assistant.md`.
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
(circulating power, or a site or per-inverter sign trip), and that latch keeps
its own red row.
+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
+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:
+59 -16
View File
@@ -142,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"
@@ -234,31 +236,62 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
"upstream computed a setpoint the actuator cannot deliver.")
# 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 BUG SIGNAL. Every number is the
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}: the loop computed {d.requested_w:+.0f} W, over "
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
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 loop's integrator reached its "
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
f"this cycle was {d.target_w:+.0f} W. Brief "
f"engagements after a large load or solar step are normal. If it "
f"persists, check that the battery is following its commands.")
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.
@@ -275,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:
@@ -363,6 +398,10 @@ class ClampWatchdog:
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 = ""
@@ -417,6 +456,10 @@ class ClampWatchdog:
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)
+132 -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:
@@ -346,6 +379,12 @@ class Decision:
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:
@@ -442,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).
@@ -449,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
@@ -553,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.
@@ -739,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
@@ -746,10 +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, float(i_bound_w), float(want),
float(clamped_w))
float(clamped_w), refused, held_by_bound)
def maintenance_charge_floor(
+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
+139 -3
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,10 +123,18 @@ 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()
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:
@@ -73,6 +146,39 @@ class HomeAssistant:
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."""
obj = await self.state(entity_id)
@@ -107,23 +213,53 @@ 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.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",
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
+28
View File
@@ -127,6 +127,7 @@ 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
@@ -317,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()."""
@@ -411,6 +432,13 @@ class Inverter:
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.
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}")
+235 -8
View File
@@ -102,6 +102,7 @@ class Notice:
title: str
body: str
section: str = "" # the DOCS.md heading, when it is not the title itself
body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English
@property
def docs(self) -> str:
@@ -110,7 +111,9 @@ class Notice:
def data(self) -> dict:
"""The `persistent_notification.create` payload."""
return {"notification_id": self.id, "title": self.title,
"message": f"{self.body}\n\n{details(self.docs)}"}
"message": f"{self.body}\n\n"
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
+ details(self.docs)}
# --- the catalogue ----------------------------------------------------------
@@ -124,6 +127,8 @@ T_CIRCULATING = "Inverters working against each other"
T_NOT_DELIVERING = "{unit} is not delivering"
S_NOT_DELIVERING = "An inverter is not delivering"
T_METER_FROZEN = "Meter reading may be stuck"
T_MAINT_FAILED = "Battery cycle did not finish"
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -132,6 +137,7 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
ID_CIRCULATING = "goodwe_circulating_power"
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
# The setting is named by its label, "Inverter rating (W)".
@@ -217,7 +223,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
packs=(), others: bool = False) -> Notice:
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
@@ -227,7 +233,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
Both are named by their setting labels, "Peak reserve (%)" and "Battery
cut-out level (%)".
cut-out level (%)". `others`: the other batteries give all they can and
it is not enough (SAFETY-87), so the limit MAY be passed.
"""
if soc_unread:
why = "The battery level cannot be read"
@@ -241,6 +248,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = "Switch off large loads, or lower the peak reserve."
if others:
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, and the other batteries cannot keep this "
f"15-minute period under your {watts(ceiling_w)} peak "
f"limit alone. {act} Home use can still draw a battery "
f"below its reserve, down to its cut-out level.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
@@ -288,6 +301,24 @@ def meter_frozen(unchanged_s) -> Notice:
f"stop control on the add-on page.")
def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice:
"""MAINT-13: a monthly cycle stopped before it was done. Said once."""
full = kind == "charge"
who = unit or "The battery"
who_nl = unit or "De batterij"
en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \
f"stopped and tries again in {days} days."
nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \
f"gestopt en probeert het over {days} dagen opnieuw."
return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING,
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
def row_maint_waits(until_text: str) -> str:
"""MAINT-13: the status row while a failed cycle waits."""
return f"Waits until {until_text} after a cycle that did not finish."
# --- the status page (UX-08 phase 2b) ----------------------------------------
# The rows of the Commissioning list and the banner at the top of the add-on
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
@@ -311,10 +342,37 @@ def seconds(value) -> str:
return f"{n} second" if n == "1" else f"{n} seconds"
# One token each, like a code, so the line keeps its length and stays greppable.
NO_REASON_SAVED, NO_REASON_CODE = "REASON_NOT_SAVED", "REASON_WITHOUT_CODE"
_REASON_CODE = re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b")
def reason_code(reason) -> str:
"""UX-11: the code in a safety reason (`STALE_READING`), for the one plain
WARNING or ERROR line that reports it. The reason with its figures goes on
a DEBUG line beside it and into the action log. Saved reasons have always
opened with their code, so an old state file reads the same way."""
if not reason:
return NO_REASON_SAVED
m = _REASON_CODE.search(str(reason))
return m.group(0) if m else NO_REASON_CODE
def times(n) -> str:
return "1 time" if int(n) == 1 else f"{int(n)} times"
def count(n, noun, plural=None) -> str:
"""`count(1, "price")` -> `1 price`, `count(3, "price")` -> `3 prices`."""
return f"{int(n)} {noun if int(n) == 1 else plural or noun + 's'}"
def when(at) -> str:
"""`27 Sep at 09:15`, as the page writes a moment. `at` is already in the
time zone the reader wants (the caller passes `price.brussels(t)`)."""
return f"{at.day} {at:%b} at {at:%H:%M}"
def for_unit(unit) -> str:
"""` for inverter-b` on a site with several inverters, else nothing."""
return f" for {unit}" if unit else ""
@@ -355,6 +413,16 @@ def banner_stopped() -> str:
return "Stopped — the inverters are set to 0 W"
def banner_setup_only() -> str:
"""UX-15: setup-only mode, where the add-on serves the page and commands
nothing (app/setuponly.py)."""
return "Setup: nothing is controlled yet"
def row_setup_only() -> str:
return "No inverter is named yet, so finish Setup and restart the add-on."
def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner.
@@ -368,6 +436,17 @@ def banner_shadow(latched: bool) -> str:
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
def banner_ha_waiting() -> str:
"""SAFETY-112: the Supervisor passes nothing on until Home Assistant has
finished starting, so the add-on can neither read nor write."""
return "Waiting for Home Assistant to finish starting"
def row_ha_waiting() -> str:
return ("Home Assistant is not answering, perhaps it is still starting. "
"Control resumes by itself when it answers.")
# Rows: control and the meter. "Control" is what the add-on does, as in
# "start control" (STYLE.md section 4).
def row_controller_starting() -> str:
@@ -383,6 +462,11 @@ def row_controller_stalled(age_s, veto_s) -> str:
f"sets the inverter to 0 W after {seconds(veto_s)}.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return f"Meter: waiting for the first reading, limit {seconds(limit_s)}"
def row_meter_stale(age_s, limit_s) -> str:
return (f"Meter: no reading for {seconds(age_s)}, limit "
f"{seconds(limit_s)}. The add-on log shows why.")
@@ -428,8 +512,9 @@ def row_peak_saturated(ceiling_w) -> str:
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> str:
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
packs=(), others: bool = False) -> str:
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
`others`: as in `peak_reserve()` (SAFETY-87)."""
if soc_unread:
why = "Battery level cannot be read"
elif floor_unknown:
@@ -437,6 +522,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
else:
why = (f"{names(packs)} battery at its peak reserve" if packs
else "Battery at its peak reserve")
if others:
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
f"limit alone. Home use still draws it lower, to the cut-out "
f"level.")
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
f"use still draws the battery below it, down to the cut-out level.")
@@ -676,6 +765,15 @@ def row_pv_partial(missing, count) -> str:
f"reading")
def row_forecast_gap(day, missing, count) -> str:
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
positions of the forecast entities with no usable reading."""
which = names([str(n) for n in missing])
many = len(missing) > 1
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
f"{which} of {count} {'have' if many else 'has'} no reading")
def row_pv_single_ignored() -> str:
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
@@ -739,6 +837,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -754,6 +858,14 @@ def row_battery_stuck(unit) -> str:
f"for now")
def row_battery_refuses(unit, sign, probing) -> str:
"""SAFETY-73. One inverter's battery refuses, the others take its share."""
way = "give" if sign > 0 else "take"
if probing:
return f"{unit}: testing if the battery will {way} power again"
return f"{unit}: the battery refuses to {way} power, the others take over"
# The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = {
"loop": "Keeping the grid near zero",
@@ -766,6 +878,15 @@ WHY_WINNER = {
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"no claim was made": "Nothing asked for power",
# UX-10: the claim reasons main.why_key() can return for a safety or
# failsafe winner. test_messages.py part 7 finds every one in the code.
"sign disagreement": "Safety stop, power went the wrong way",
"unsplit": "The power could not be split, all at 0 W",
# SAFETY-52: the loop's two words for a battery that gives nothing.
"battery refuses": "The battery refuses to give power",
"battery probe": "Testing if the battery gives power again",
# SAFETY-73: a refusing inverter's own claim, closed or probing.
"refused": "A battery refuses power, the others take over",
}
WHY_LIMIT = {
"peak(quarter peak)": "the peak limit",
@@ -776,23 +897,36 @@ WHY_LIMIT = {
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
def why(winner, target_w, bound_by=None, *, contradiction=False,
raised=False) -> str:
"""`winner` is the arbiter's winning source, or its reason for the two
that share a source ("stopped", "inputs missing", "no claim was made").
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)"."""
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)".
`raised` is True when that limit pushed the power UP (the peak limit
asking for more): then it did not hold anything back (OPT-11 P1 test F2).
"""
if contradiction:
return "Two limits clash, so every inverter is set to 0 W"
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
if bound_by:
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
str(bound_by).split("(")[0], "another limit")
out += f", held back by {limit}"
out += f", raised for {limit}" if raised else \
f", held back by {limit}"
return out
def why_missing(what, limit_s) -> str:
"""UX-11: past the hold, which reading is missing and what it does."""
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
def why_waiting(missing_s) -> str:
"""A reading went missing a moment ago: control holds and waits."""
return f"A reading is missing for {seconds(missing_s)}, control waits"
@@ -806,11 +940,16 @@ HINTS = {
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"setup_only": "In setup, nothing is sent to any inverter",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
"battery_gives": "The battery refusal ended, control asks for power again",
"unit_refuses": "{unit}: the battery refuses power, the others take over",
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
"all_blocked": "A power request was blocked, inverters set to 0 W",
@@ -823,6 +962,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -831,6 +971,9 @@ HINTS = {
"trade_charge": "Trading: charging while the price is low",
"trade_give": "Trading: giving power while the price is high",
"trade_off": "Trading: waiting for a better price",
"forecast_read": "Solar forecast read, the day plan uses it",
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
@@ -849,9 +992,51 @@ HINTS = {
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
"maintenance": "Battery maintenance: {phase}",
"maint_paused": "Maintenance charging paused to stay under the peak limit",
"maint_failed": "The battery cycle did not finish, so it waits before trying again",
"pack_on": "{unit}: battery switched on",
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
"pack_mode": "{unit}: battery mode changed",
"pack_sell": "{unit}: selling switched",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
# OPT-11: why a battery switch or mode change was refused (the page shows it).
PACK_REFUSED = {
"off": "Battery modes are off. Turn on “Battery modes” first.",
"unit": "There is no battery with that name.",
"value": "That choice is not possible for this battery.",
# OPT-11 P3, owner decision 2026-09-29: selling is off for now.
"sell_off": "Selling is off for now.",
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
"retained": "An old saved command is not used.",
"size": "That command is too long.",
"encoding": "That command is not plain text.",
"topic": "That is not a battery command.",
}
def pack_refused(why: str) -> str:
return PACK_REFUSED[why]
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
# the battery the sentence is about (for "busy", the one that is cycling). The
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
MAINT_REFUSED = {
"unit": "There is no battery with that name.",
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
"off": "{unit} is switched off, so it cannot start a cycle.",
"idle": "{unit} is not running a cycle.",
}
def maint_refused(why: str, unit: str = "") -> str:
return MAINT_REFUSED[why].format(unit=unit)
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
@@ -893,11 +1078,18 @@ def status_samples() -> list:
("banner_peak_reserve", None, banner_peak_reserve()),
("banner_power_too_high", None, banner_power_too_high()),
("banner_shadow", None, banner_shadow(False)),
("banner_setup_only", None, banner_setup_only()),
("row_setup_only", AMBER, row_setup_only()),
("banner_shadow", None, banner_shadow(True)),
("banner_not_ready", None, banner_not_ready(
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
("banner_ha_waiting", None, banner_ha_waiting()),
("row_ha_waiting", RED, row_ha_waiting()),
("row_controller_starting", GREEN, row_controller_starting()),
("row_controller_ran", GREEN, row_controller_ran(2)),
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
("row_meter_stale", RED, row_meter_stale(40, 10)),
("row_meter_waiting", AMBER, row_meter_waiting(30)),
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
("row_meter_frozen", RED, row_meter_frozen()),
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
@@ -906,6 +1098,9 @@ def status_samples() -> list:
("row_peak_guard_off", AMBER, row_peak_guard_off()),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
others=True)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=True, floor_unknown=False)),
("row_peak_reserve", RED, row_peak_reserve(
@@ -959,6 +1154,8 @@ def status_samples() -> list:
"entity - it cannot be read in watts")),
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
@@ -974,13 +1171,21 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
]
@@ -1002,6 +1207,8 @@ def samples() -> list:
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"], others=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
@@ -1009,6 +1216,8 @@ def samples() -> list:
not_delivering("inverter-b", "inverter_b"),
meter_frozen(600),
meter_frozen(90),
maint_failed("charge", 7),
maint_failed("drain", 7, "inverter-b", "inverter_b"),
]
@@ -1052,6 +1261,24 @@ LABELS = {
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
"slew_w": "Largest change per cycle",
"shadow_mode": "Shadow mode",
# UX-13: named by the settings page's warnings (validate.Note).
"integrator_max_w": "Catch-up limit",
"maintenance_discharge_w": "Draw-down rate",
"peak_cap_w": "Peak limit",
"peak_margin_w": "Margin below the peak limit",
"peak_forecast_entity": "15-minute demand forecast entity",
"meter_entity": "Grid power entity",
"soc_entity": "Battery level entity",
"batt_entity": "Battery power entity",
"pv_entity": "Solar production entity",
"pv_entities": "Solar production entities, per inverter",
"pv_forecast_entity": "Solar forecast, today",
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
"pv_forecast_entities": "Several solar forecasts, today",
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
"solar_trust_pct": "Trust in the forecast",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
}
+276
View File
@@ -0,0 +1,276 @@
"""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;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST)
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
"off", "house", "hold", "give", "sell", "charge", "rest")
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# The smallest grid charge worth starting, in W. Under it the other batteries
# would stop covering the house for less than they give.
START_W = 500.0
@dataclass(frozen=True)
class Pack:
"""One battery as this module needs it."""
name: str
use: bool
mode: str
sell: bool
rating_w: float
soc: float | None
capacity_kwh: float | None
live: bool = True
idle: bool = True # not in a maintenance phase
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
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
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:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
return Tick(ranges, roles, None, 0.0,
why if (in_cheap or in_sell) else None)
def size(tick: Tick, bound, headroom_w) -> float:
"""The site trade in W, inside what the batteries may place with their
modes (`bound` = main's loop bound) and, for a charge, the billed
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
is never filed)."""
if tick.action is None or bound is None:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))
def weights(roles: dict, base, target_w: float):
"""Who carries the site target first (design 3.5). Selling: the sellers.
Giving power: batteries in a give run before Solar first. Charging from
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
the ranges above are the permission."""
names = sorted(roles)
first = None
if target_w > 0 and any(r == SELL for r in roles.values()):
first = {n for n in names if roles[n] == SELL}
elif target_w > 0 and any(r == GIVE for r in roles.values()):
first = {n for n in names if roles[n] in (GIVE, SELL)}
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
first = {n for n in names if roles[n] == CHARGE}
if not first:
return base
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
out = {n: (w[n] if n in first else 0.0) for n in names}
return out if any(v > 0.0 for v in out.values()) else base
+182 -15
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
@@ -375,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
@@ -389,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]
@@ -400,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,
@@ -427,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
@@ -448,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:
@@ -470,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()
@@ -485,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
+33 -4
View File
@@ -48,6 +48,7 @@ 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)
@@ -311,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."""
@@ -524,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.
@@ -574,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)
@@ -583,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:
@@ -618,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:
+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
+206 -43
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,7 +399,7 @@ 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
@@ -402,27 +408,27 @@ 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"
@@ -453,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)
@@ -474,16 +486,24 @@ 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
@@ -524,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.
@@ -566,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?
@@ -712,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
@@ -731,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)
@@ -742,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:
@@ -754,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
@@ -762,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.
@@ -786,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")
@@ -804,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:
@@ -1032,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)
+8 -7
View File
@@ -150,6 +150,7 @@ from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
from . import messages
from .custom_formula import (
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
@@ -1738,11 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("The price feed gave no price for %d of %d times (index "
"%s). Those times are left out, not counted as free.",
len(dropped), len(times),
_LOG.warning("The price feed gave no price for %d of %s, at index "
"%s. Those are left out, not counted as free.",
len(dropped), messages.count(len(times), "time"),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
+ (" and more" if len(dropped) > 5 else ""))
if not out:
raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of "
@@ -2106,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
pid = provider_id(opts)
if not provider_configured(opts):
return PriceRead.no_data(
"no price provider configured (price_provider is 'off'); "
"§11.4: static profile, plan provisional, no arbitrage")
"no price feed is set (price_provider is 'off'), so the fixed "
"tariff is used, the plan is estimated and there is no trading")
cls = PROVIDERS.get(pid)
if cls is None:
return PriceRead.no_data(
f"unknown price provider {pid!r}; shipped: "
f"unknown price provider {pid!r}. The known ones are "
f"{', '.join(sorted(PROVIDERS))}")
caps = cls.capabilities()
+27 -24
View File
@@ -164,6 +164,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from . import messages
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
f"fall outside {messages.when(brussels(since))} to "
f"{messages.when(brussels(until))}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
f"no hours could be filled from yesterday's prices: {reason}. So "
f"the fixed tariff is used, the plan is estimated and there is no "
f"trading")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
# feed's is the POINT; nothing derived from one answers for the other.
total_h = len({_utc_hour(p.start) for p in out})
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; the feed published %d "
"points over the other %d hours",
carried, total_h, len(out) - carried, total_h - carried)
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
"The feed published %s over the other %s.",
carried, messages.count(total_h, "hour"),
messages.count(len(out) - carried, "price"),
messages.count(total_h - carried, "hour"))
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
@@ -525,8 +528,8 @@ class _Permitted(Gate):
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
f"from {messages.when(brussels(pts[0].start))}")
class _Blocked(Gate):
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
f"{action.name} needs a price and there is none: {r.reason}. A "
f"missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and no price "
f"covers {messages.when(brussels(u))}. An hour with no price "
f"is not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and the price for "
f"{messages.when(brussels(u))} is only estimated. It can start "
f"once the day-ahead prices are published, around 14:00 to "
f"16:00")
pts.append(p)
return Gate.permit(action, pts)
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
_LOG.warning("An action that cannot be undone does not start: %s.",
gate.reason)
return gate
+54 -37
View File
@@ -186,8 +186,9 @@ import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from . import messages
from .price import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday
@@ -345,8 +346,19 @@ def _row_of(day: str, points: int, hours: int) -> str:
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} price{'' if points == 1 else 's'} "
f"over {hours} hour{'' if hours == 1 else 's'})")
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
def day_words(day) -> str:
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
not a date is shown as it is, because this feeds a status row."""
try:
d = date.fromisoformat(str(day))
except ValueError:
return str(day)
return f"{d.day} {d:%b}"
def _bad_rows(rec) -> int:
@@ -393,9 +405,8 @@ class PriceHistory:
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
"to carry and the horizon stays as short as the feed",
self.last_error)
_LOG.info("No price history is kept (%s), so missing hours are "
"not filled from yesterday's prices.", self.last_error)
return
self.ok = True
self._load()
@@ -449,15 +460,15 @@ class PriceHistory:
# would not notice a writer this build has never met.
if not isinstance(blob, dict) or isinstance(found, bool) \
or not isinstance(found, int) or found != SCHEMA:
self.last_error = f"schema {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather "
"than guessing what its points mean",
self.last_error = f"version {found!r}, not {SCHEMA}"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
days = blob.get("days")
if not isinstance(days, dict):
self.last_error = "no `days` object"
_LOG.warning("price history at %s has %s", self.path, self.last_error)
self.last_error = "no days in it"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
self._days = self._usable_days(days)
@@ -504,20 +515,22 @@ class PriceHistory:
kept[key] = rec
notes = []
if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
f"dropped, " + ", ".join(day_words(d)
for d in sorted(dropped)))
if malformed:
notes.append(
f"{sum(malformed.values())} malformed point row(s) in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
f"{messages.count(sum(malformed.values()), 'bad price')} in "
+ ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(malformed.items())))
if notes:
# Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day.
self.last_error = "; ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts "
"anyway and the horizon is as short as the feed makes "
"it", self.path, self.last_error)
self.last_error = " and ".join(notes)
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
"and fills in only what the rest holds.",
self.path, self.last_error)
return kept
def _save(self) -> bool:
@@ -680,10 +693,11 @@ class PriceHistory:
# undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
_LOG.warning("price history at %s: %s - the record is rewritten "
"without them and the refresh continues",
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
f"from " + ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(junked.items())))
_LOG.warning("Price history at %s: %s. The file is saved without "
"them and the price update goes on.",
self.path, self.last_error)
self._prune(stamp)
if not self._save():
@@ -752,21 +766,23 @@ class PriceHistory:
if self.last_error and not self._days:
return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so "
f"§8.3 has no profile to carry forward for {want}; the plan "
f"covers only the hours the feed published")
f"there are no prices from yesterday to fill in for "
f"{day_words(want)}. The plan covers only the hours the feed "
f"published")
rec = self._days.get(want)
if rec is None:
have = ", ".join(sorted(self._days)) or "no days at all"
have = (", ".join(day_words(d) for d in sorted(self._days))
or "no days at all")
return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so "
f"§8.3 has no profile to carry forward. A record for another "
f"day is not a yesterday")
f"no published prices are kept for {day_words(want)}, only for "
f"{have}, so there are no prices from yesterday to fill in. A "
f"record for another day is not a yesterday")
points, bad = _parse_points(rec.get("points"))
if not points:
why = bad or "it is empty"
return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an "
f"empty series is not a free day")
f"the {day_words(want)} record holds no usable price, {why}, "
f"and an empty series is not a free day")
try:
product = PriceProduct(rec.get("product"))
except ValueError:
@@ -775,8 +791,9 @@ class PriceHistory:
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("Price history: %s in the %s record were dropped, "
"and %d points were kept.", bad, want, len(points))
_LOG.warning("Price history: %s dropped from the %s record, and "
"%s kept.", bad, day_words(want),
messages.count(len(points), "price"))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
@@ -805,7 +822,7 @@ class PriceHistory:
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
held = "; ".join(_row_of(d, *_held(self._days[d]))
held = ", ".join(_row_of(d, *_held(self._days[d]))
for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written.
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
continue
out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
class NoHistory:
@@ -861,5 +878,5 @@ class NoHistory:
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no "
"profile to carry forward")
"this caller keeps no price history, so there are no prices from "
"yesterday to fill in")
+203
View File
@@ -0,0 +1,203 @@
"""OPT-11 P3: price runs. Which consecutive firm price slots are cheap, which
are expensive, and which one a battery may sell in.
Pure: prices, the time and a few energy figures in, a `RunPlan` out. No I/O,
no clock. `modetrade.py` turns the plan into each battery's range and the
site's trade; main.py rebuilds the plan at most once per 15 minutes and keeps
a run that has started until its end (`keep_started()`).
The owner's SAFETY-46 answer (2026-09-25): a trade is a run of consecutive
slots, not one 15-minute interval. OPT-11-design section 2 ("Prices") and
DECISIONS/OPT-11-battery-modes.md have the record.
* The CHEAP run is the cheapest consecutive firm window long enough to put
the planned energy in at the chargers' combined rating.
* A slot is EXPENSIVE when its price is above the cheap run's mean by at
least the required spread (`cycle_cost_threshold_eur`, widened as
`TariffPlan.arbitrage()` widens it). Give runs are the maximal consecutive
expensive slots. Holding batteries cover the house in them. The mean is
the lower of the next cheap run's and `ref_eur_kwh`, the cheap run the
batteries last filled in (main.py keeps it for 12 h): energy bought at
03:00 is still worth that price at 08:00, when the next cheap run in sight
is today's dearer midday (measured: without it a Cheap hours battery spent
its night charge on flat morning prices, then had nothing at 19:00).
* The SELL run is the dearest consecutive window inside one give run long
enough to sell the sellers' energy at their rating, with no slot below 0
(section 17 of the spec: never export at a negative price).
* A provisional (carried) price is never part of any run (section 8.3).
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from .price import _cover_table
# How far ahead runs are looked for. A day: tonight's cheap hours and the next
# evening's expensive ones, without holding a battery for a peak two days out.
HORIZON = timedelta(hours=24)
# No plan, and why (the page words are in homeview / ui_text).
NO_PRICES = "no_prices" # fewer than two firm slots ahead
NOT_WORTH = "not_worth" # no slot clears the required spread
@dataclass(frozen=True)
class Run:
start: datetime
end: datetime
mean_eur_kwh: float
def holds(self, when: datetime) -> bool:
return self.start <= when < self.end
@dataclass(frozen=True)
class RunPlan:
"""What the prices say for the next day. `reason` is None when there is
a plan (a cheap run and at least one give run), else why there is none."""
cheap: Run | None
give: tuple
sell: Run | None
level_eur_kwh: float | None
reason: str | None
# A slot after the cheap run clears the spread over the CHEAP RUN'S OWN
# price (not the reference): only then is buying in it worth a cycle.
charge_worth: bool = False
@property
def on(self) -> bool:
return self.reason is None
def give_at(self, when):
return next((g for g in self.give if g.holds(when)), None)
def next_give(self, when):
"""The give run under way at `when`, else the next one, else None."""
return next((g for g in self.give if g.end > when), None)
def charge_useful(self) -> bool:
"""Charging in the cheap run pays only if, after it, a slot clears
the spread over the cheap run's own price."""
return self.cheap is not None and self.charge_worth
def firm_slots(points, now: datetime, horizon: timedelta = HORIZON) -> list:
"""`[(start, end, eur_kwh)]`, ascending: the firm slots that have not
ended and start within the horizon. A slot's span is TARIFF-14's rule
(`price._cover_table()`), so 15-minute and hourly series both work."""
out = []
for start, end, p in _cover_table(points):
if p.provisional or end <= now or start >= now + horizon:
continue
if end > start:
out.append((start, end, float(p.eur_per_kwh)))
return out
def _segments(slots) -> list:
"""Split slots into runs of consecutive ones (each starts where the last
ended). A gap or a provisional slot breaks a run."""
segs, cur = [], []
for s in slots:
if cur and s[0] != cur[-1][1]:
segs.append(cur)
cur = []
cur.append(s)
if cur:
segs.append(cur)
return segs
def _hours(s) -> float:
return (s[1] - s[0]).total_seconds() / 3600.0
def _mean(win) -> float:
h = sum(_hours(s) for s in win)
return sum(_hours(s) * s[2] for s in win) / h
def _windows(segs, need_h):
"""Every consecutive window that lasts at least `need_h` hours, shortest
such from each start. If no segment is that long, each whole segment."""
out = []
for seg in segs:
for i in range(len(seg)):
h, j = 0.0, i
while j < len(seg) and h < need_h - 1e-9:
h += _hours(seg[j])
j += 1
if h >= need_h - 1e-9:
out.append(seg[i:j])
return out or [seg for seg in segs]
def need_hours(kwh, kw, slots) -> float:
"""How long a run must last: `kwh` at `kw`, and at least one slot."""
one = min((_hours(s) for s in slots), default=1.0)
if not (kwh and kw) or kwh <= 0 or kw <= 0:
return one
return max(one, float(kwh) / float(kw))
def plan(points, now: datetime, *, required_eur_kwh: float, charge_kwh,
charge_kw, sell_kwh, sell_kw, horizon: timedelta = HORIZON,
ref_eur_kwh=None) -> RunPlan:
"""The run plan for the next `horizon`. Ties go to the earliest window."""
if not (isinstance(required_eur_kwh, (int, float))
and math.isfinite(required_eur_kwh) and required_eur_kwh >= 0):
raise ValueError("required_eur_kwh must be a finite number >= 0")
slots = firm_slots(points, now, horizon)
if len(slots) < 2:
return RunPlan(None, (), None, None, NO_PRICES)
segs = _segments(slots)
wins = _windows(segs, need_hours(charge_kwh, charge_kw, slots))
best = min(wins, key=lambda w: (round(_mean(w), 9), w[0][0]))
cheap = Run(best[0][0], best[-1][1], _mean(best))
base = cheap.mean_eur_kwh if ref_eur_kwh is None else min(
cheap.mean_eur_kwh, float(ref_eur_kwh))
level = base + float(required_eur_kwh)
# Expensive: clears the spread, strictly dearer than the cheap mean, and
# not part of the cheap run itself (a threshold of 0 must not overlap).
dear = [s for s in slots
if s[2] >= level and s[2] > cheap.mean_eur_kwh
and not (cheap.start <= s[0] < cheap.end)]
give_segs = _segments(dear)
give = tuple(Run(g[0][0], g[-1][1], _mean(g)) for g in give_segs)
worth = any(s[0] >= cheap.end and s[2] >= cheap.mean_eur_kwh
+ float(required_eur_kwh) for s in dear)
if not give:
return RunPlan(cheap, (), None, level, NOT_WORTH)
# Sell: inside one give run, never over a slot priced below 0.
sellable = [part for g in give_segs
for part in _segments([s for s in g if s[2] >= 0.0])]
sell = None
if sellable:
sw = _windows(sellable, need_hours(sell_kwh, sell_kw, slots))
top = max(sw, key=lambda w: (round(_mean(w), 9),
-w[0][0].timestamp()))
sell = Run(top[0][0], top[-1][1], _mean(top))
return RunPlan(cheap, give, sell, level, None, worth)
def keep_started(old: RunPlan | None, new: RunPlan, now: datetime) -> RunPlan:
"""A run under way keeps going to its end (no flapping on a new plan): the
cheap run, the give run and the sell run each come from `old` while `old`
holds `now` in it. The rest is `new`. A safety or peak rule can still stop
a run at any tick; that is not decided here."""
if old is None or not old.on:
return new
cheap = old.cheap if old.cheap and old.cheap.holds(now) else new.cheap
sell = old.sell if old.sell and old.sell.holds(now) else new.sell
g_old = old.give_at(now)
give = new.give
if g_old is not None:
give = (g_old,) + tuple(g for g in new.give
if g.start >= g_old.end)
if cheap is new.cheap and sell is new.sell and g_old is None:
return new
worth = old.charge_worth if cheap is old.cheap else new.charge_worth
return RunPlan(cheap, give, sell, new.level_eur_kwh if new.on
else old.level_eur_kwh, None if give else new.reason,
worth)
+29 -9
View File
@@ -82,6 +82,7 @@ import os
import aiohttp
from . import messages
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
@@ -130,11 +131,16 @@ class SaveRefused(Exception):
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
def __init__(self, kind, problems=(), changed=(), stored=None,
moved=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
# against stored-now - when the caller knows what it opened. None
# when it does not. `changed` also holds the operator's own edits.
self.moved = None if moved is None else list(moved)
super().__init__(f"save refused ({kind})")
@@ -287,7 +293,7 @@ async def problems_for(opts: dict, hass=None) -> list:
return problems
async def problems_and_ranges(opts: dict, hass=None):
async def problems_and_ranges(opts: dict, hass=None, notes=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
@@ -302,6 +308,8 @@ async def problems_and_ranges(opts: dict, hass=None):
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
one. That was the first shape of this and it disagreed with itself.
UX-13: a list passed as `notes` collects the validator's warnings too.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
@@ -328,7 +336,7 @@ async def problems_and_ranges(opts: dict, hass=None):
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
found = list(validate_startup_config(opts, notes=notes))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
@@ -346,7 +354,8 @@ async def problems_and_ranges(opts: dict, hass=None):
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems = list(validate_startup_config(opts, dev_min, dev_max,
notes=notes))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
@@ -355,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None):
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
hass=None, base=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
@@ -378,11 +387,17 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
`base`, when given, is the blob the page opened (the one `expect` is the
fingerprint of). A stale refusal then also names the keys that moved
underneath it, so the page does not list the operator's own edits among
the settings "changed elsewhere" (UX-13 P2 test, finding a).
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("The settings page did not save, because of %d "
"problems. The first: %s", len(problems), problems[0])
_LOG.warning("The settings page did not save, because of %s. The "
"first: %s", messages.count(len(problems), "problem"),
problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
@@ -390,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
moved = (changed_keys(base, stored)
if isinstance(base, dict) and fingerprint(base) == expect
else None)
keys = changed if moved is None else moved
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
", ".join(keys) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored,
moved=moved)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
+90
View File
@@ -0,0 +1,90 @@
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
`applies()` says when: the startup gate's only refusal is that no inverter is
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
controller. Nothing that can command is built: no Controller, no inverter, no
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
before anything is sent (hass.py), and `/action` refuses too (web.py).
"""
import asyncio
import contextlib
import logging
import signal
from datetime import datetime, timezone
from . import messages, web
from .inverter import roster_entries
from .validate import actuator_writers, no_power_request_problem
_LOG = logging.getLogger("goodwe.setup")
def applies(opts: dict, problems) -> bool:
"""True when the options name no inverter at all and that is the gate's
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
return (not actuator_writers(opts) and not roster_entries(opts)
and list(problems) == [no_power_request_problem()])
class SetupOnly:
"""What web.build_app() needs from a controller, and nothing that writes.
It holds no inverter and no publisher. `hass` must be read-only."""
setup_only = True
auto = False # control is never started here
grid = None # no meter is read here
inverters = ()
def __init__(self, opts: dict, hass, store):
if getattr(hass, "read_only", False) is not True:
raise ValueError("setup-only mode needs a read-only HomeAssistant")
self.o, self.hass, self.store = opts, hass, store
self.events: list[str] = []
self.hint = ""
def log_event(self, msg: str, hint: str | None = None) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def status_payload(self, lang: str = "en") -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
# web.py answers 409 before it gets here. Nothing is done either way.
_LOG.warning("The page asked for an action in setup, so nothing "
"was done.")
return None
async def shutdown(self) -> None:
"""Nothing to wind down: nothing was ever commanded."""
async def serve(opts: dict, hass, store, *, port: int) -> None:
"""Serve the page in setup-only mode until the Supervisor stops us."""
controller = SetupOnly(opts, hass, store)
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
"controls nothing. Open its page to finish setup.")
controller.log_event("setup-only mode: no inverter named, nothing is "
"commanded", hint=messages.hint("setup_only"))
runner = await web.start(controller, port=port)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
try:
await stop.wait()
finally:
await runner.cleanup()
_LOG.info("stopped")
+350
View File
@@ -0,0 +1,350 @@
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
this later with the add-on's own forecast (owner answer, 2026-09-27).
Two options name the sensors: `pv_forecast_entity`, the solar energy still
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
is only needed for a cheap run before midnight). Supported, by the entities
they create (DOCS.md lists them):
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
`sensor.energy_production_tomorrow`. The state only; no hourly split.
* Solcast PV Forecast (HACS `solcast_solar`):
`sensor.solcast_pv_forecast_forecast_remaining_today` and
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
`[{period_start, pv_estimate}]`, kW per period.
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
`wh_period`: `{period start: Wh}`.
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
state only. The unit is read, never guessed (the rule `pv.watts()` has).
Without an hourly split, the day's energy is spread over a fixed bell from
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
not usable makes that day's sum unusable, never a partial sum.
DECISIONS/OPT-17-several-forecast-entities.md.
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .hass import BAD_STATES
from .tariff import BILLING_TZ
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
# Stale after 3 h. The integrations above refresh at least hourly, so a
# 2 h limit would flag a site whose hourly poll was missed once.
FRESH_S = 3 * 3600.0
# The bell a state-only forecast is spread over, in local hours.
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# Why there is no forecast (the page words are in ui_text `home_words`).
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
# OPT-17: (single key, list key) per day. A set list replaces the single key;
# the validator refuses both set for the same day.
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
"pv_forecast_tomorrow_entities")
@dataclass(frozen=True)
class Day:
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
day0: datetime # local midnight, as UTC
kwh: float
periods: tuple = ()
@dataclass(frozen=True)
class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
def between(self, t0: datetime, t1: datetime) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
if t1 <= t0:
return 0.0
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
total = 0.0
for day in (self.today, self.tomorrow):
if day is None:
continue
a = max(t0, day.day0)
b = min(t1, day.day0 + timedelta(days=1))
if b > a:
total += _day_between(day, a, b, t0)
return total
def _local_hour(t: datetime) -> float:
lt = t.astimezone(BILLING_TZ)
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
def _bell(h: float) -> float:
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
if h <= BELL_FROM_H:
return 0.0
if h >= BELL_TO_H:
return 1.0
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
return (1.0 - math.cos(math.pi * x)) / 2.0
def _hours_since(day0, t):
return (t - day0).total_seconds() / 3600.0
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
if day.periods:
out = 0.0
for s, e, kwh in day.periods:
lo, hi = max(a, s), min(b, e)
if hi > lo:
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
return out
# State only: `kwh` is what is left from `max(now, day0)` to the day's
# end, spread over the bell.
h_from = _hours_since(day.day0, max(now, day.day0))
left = 1.0 - _bell(h_from)
if left <= 1e-9:
return 0.0
share = (_bell(_hours_since(day.day0, b))
- _bell(_hours_since(day.day0, a))) / left
return day.kwh * max(0.0, share)
def _parse_time(v):
try:
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
def _periods(attrs: dict, day0: datetime) -> tuple:
"""The hourly split, if the sensor has one, for the day at `day0`."""
d1 = day0 + timedelta(days=1)
out = []
for key in ("detailedForecast", "detailedHourly"):
rows = attrs.get(key)
if isinstance(rows, list) and rows:
starts = []
for r in rows:
if not isinstance(r, dict):
continue
t = _parse_time(r.get("period_start"))
try:
kw = float(r.get("pv_estimate"))
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(kw) and kw >= 0:
starts.append((t, kw))
starts.sort()
for i, (t, kw) in enumerate(starts):
step = (starts[i + 1][0] - t if i + 1 < len(starts)
else timedelta(minutes=30 if key ==
"detailedForecast" else 60))
if step <= timedelta(0) or step > timedelta(hours=1):
step = timedelta(minutes=30 if key == "detailedForecast"
else 60)
if day0 <= t < d1:
out.append((t, t + step,
kw * step.total_seconds() / 3600.0))
if out:
return tuple(out)
wh = attrs.get("wh_period")
if isinstance(wh, dict) and wh:
rows = []
for k, v in wh.items():
t = _parse_time(k)
try:
e = float(v)
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(e) and e >= 0:
rows.append((t, e / 1000.0))
rows.sort()
for i, (t, kwh) in enumerate(rows):
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
hours=1)
if nxt - t > timedelta(hours=1) or nxt <= t:
nxt = t + timedelta(hours=1)
if day0 <= t < d1:
out.append((t, nxt, kwh))
return tuple(out)
def _day(state, day0, now, *, today):
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
if state is None:
return None, MISSING
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return None, MISSING
try:
v = float(raw)
except ValueError:
return None, BAD
attrs = state.get("attributes") or {}
unit = str(attrs.get("unit_of_measurement") or "").strip()
if unit not in KWH_PER:
return None, UNIT
kwh = v * KWH_PER[unit]
if not math.isfinite(kwh) or kwh < 0:
return None, BAD
periods = _periods(attrs, day0)
if periods and today:
# The split is the whole day; keep what is still to come, scaled so
# it adds up to the state (the state is what the integration says
# is left).
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
/ (e - s).total_seconds()))
for s, e, k in periods)
if left > 1e-6:
f = kwh / left
periods = tuple((s, e, k * f) for s, e, k in periods)
return Day(day0, kwh, periods), None
def _reported(state):
ts = [_parse_time(state.get(k)) for k in
("last_reported", "last_updated", "last_changed")]
ts = [t for t in ts if t is not None]
return max(ts) if ts else None
def entities(opts: dict, *, tomorrow=False) -> tuple:
"""The forecast entities one day names, in configured order: the list
if it has any entry, else the single key, else (). A repeated id is
dropped, as `pv.entities()` does: it would count one forecast twice."""
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
listed = [e for e in (str(x or "").strip()
for x in (opts.get(many) or [])) if e]
if listed:
return tuple(dict.fromkeys(listed))
e = str(opts.get(one) or "").strip()
return (e,) if e else ()
def _bounds(now):
here = now.astimezone(BILLING_TZ)
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nd = d0.date() + timedelta(days=1)
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
def _today(state, day0, now):
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
today, why = _day(state, day0, now, today=True)
if today is None:
return None, None, why
at = _reported(state)
# A reading from before local midnight is yesterday's "left today" (0
# after sunset) until the integration refreshes: stale, not a dark day.
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
or (at - now).total_seconds() > 300:
return None, None, STALE
return today, at, None
def _tomorrow(state, day0, day1, now):
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
t, why = _day(state, day1, now, today=False)
if t is None:
return None, why
t_at = _reported(state)
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
return None, STALE
return t, None
def read(today_state, tomorrow_state, now: datetime, *,
configured=True):
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
(the option is not set, or the sensor is missing): the forecast then
covers today only."""
if not configured:
return None, NOT_SET
day0, day1 = _bounds(now)
today, at, why = _today(today_state, day0, now)
if today is None:
return None, why
tomorrow = None
if tomorrow_state is not None:
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
return Forecast(today, tomorrow, at), None
def _sum(days):
"""One Day from several members' Days. The hourly split is kept only if
every member has one: the splits are then joined (`_day_between()`
adds overlapping periods). Else the total goes on the bell."""
if len(days) == 1:
return days[0]
periods = ()
if all(d.periods for d in days):
periods = tuple(sorted(p for d in days for p in d.periods))
return Day(days[0].day0, sum(d.kwh for d in days), periods)
def read_sum(today, tomorrow, now: datetime):
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
`(entity, state)`, one per configured member, in configured order.
Each member is read exactly as `read()` reads one sensor, in its own
unit. A member that is missing, unavailable, stale, not in an energy
unit or bad makes that DAY unusable, as one such sensor does today: no
today means no forecast, no tomorrow means today only. A partial sum
would under-count the sun and mislead the day plan.
`gaps` is `((day, position, entity, why), ...)` for every unusable
member (`day` "today" or "tomorrow", `position` 1-based), so the page
and the log can name it. With one member per day the forecast is what
`read()` gives (test_opt17.py proves it)."""
if not today:
return None, NOT_SET, ()
day0, day1 = _bounds(now)
gaps, days, ats, why0 = [], [], [], None
for i, (ent, st) in enumerate(today, 1):
d, at, why = _today(st, day0, now)
if d is None:
gaps.append(("today", i, ent, why))
why0 = why0 or why
else:
days.append(d)
ats.append(at)
tdays = []
for i, (ent, st) in enumerate(tomorrow, 1):
t, why = _tomorrow(st, day0, day1, now)
if t is None:
gaps.append(("tomorrow", i, ent, why))
else:
tdays.append(t)
gaps = tuple(gaps)
if why0 is not None:
return None, why0, gaps
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
# The oldest member's report is when the sum was last fully known.
return Forecast(_sum(days), tday, min(ats)), None, gaps
+246
View File
@@ -0,0 +1,246 @@
/* UX-13 page styles. Colours follow the approved mockup (UX-13-mockup.html,
version 2) and prefers-color-scheme. No web fonts, no external files. */
:root{
--bg:#f3f4f6;--card:#ffffff;--ink:#1b1d21;--muted:#5b626c;--faint:#8a919b;--line:#e2e5e9;
--accent:#1c64d9;--accent-soft:#e6efff;--on-accent:#ffffff;
--ok:#17773a;--ok-bg:#e6f4eb;--warn:#955300;--warn-bg:#fcefd9;--bad:#c02626;--bad-bg:#fce8e8;--info:#5b626c;--info-bg:#eceef1;
--solar:#c98a00;--house:#6c47c2;--grid:#56657a;--batt:#17773a;
--cheap:#8fcca4;--dear:#f0ad72;--mid:#cfd5dc;--track:#dde1e6;--shadow:0 1px 2px rgba(0,0,0,.05);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}}
:root[data-theme="dark"]{
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
button,input,select{font:inherit;color:inherit}
a{color:var(--accent)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.top{display:flex;align-items:center;gap:12px;padding:12px 16px 8px;max-width:820px;margin:0 auto}
.logo{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);display:grid;place-items:center;color:var(--accent);flex:none}
.app{font-weight:700;font-size:17px;line-height:1.2}
.pill{display:flex;flex-wrap:wrap;align-items:center;gap:0 6px;font-size:13px;color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none}
.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
.tabs{display:flex;gap:4px;max-width:820px;margin:0 auto;padding:0 16px;border-bottom:1px solid var(--line)}
.tab{border:0;background:none;padding:10px 12px;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;min-height:44px;position:relative}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.tab svg{width:20px;height:20px}
.badge{font-size:11px;font-weight:700;background:var(--warn);color:var(--card);border-radius:9px;padding:0 6px;line-height:18px}
.badge.bad{background:var(--bad)}
@media (max-width:640px){
.tabs{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--card);border-top:1px solid var(--line);border-bottom:0;padding:0 4px env(safe-area-inset-bottom);justify-content:space-around;gap:0}
.tab{flex-direction:column;gap:0;font-size:11px;padding:6px 4px;border-bottom:0;border-top:2px solid transparent;flex:1;min-width:0}
.tab[aria-selected="true"]{border-top-color:var(--accent)}
.tab .badge{position:absolute;top:2px;left:calc(50% + 6px)}
.wrap{padding-bottom:96px!important}
}
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 40px}
h2{font-size:15px;margin:0 0 10px;font-weight:650}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:var(--shadow)}
.muted{color:var(--muted)} .small{font-size:13px} .tiny{font-size:12px}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.spread{justify-content:space-between}
.grow{flex:1 1 auto;min-width:0}
.banner{border-radius:12px;padding:12px 14px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start;font-weight:600}
.banner .ic{flex:none;margin-top:2px}
.b-ok{background:var(--ok-bg);color:var(--ok)} .b-warn{background:var(--warn-bg);color:var(--warn)} .b-bad{background:var(--bad-bg);color:var(--bad)}
.banner small{display:block;font-weight:400;color:var(--ink);margin-top:2px}
.tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
@media (max-width:640px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
.tile{border:1px solid var(--line);border-radius:10px;padding:10px;min-width:0}
.tile .lab{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px}
.tile .val{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.25;margin-top:2px}
.tile .val small{font-size:13px;font-weight:500;color:var(--muted)}
.tile .sub{font-size:12px;color:var(--muted)}
.sw-solar{color:var(--solar)} .sw-house{color:var(--house)} .sw-grid{color:var(--grid)} .sw-batt{color:var(--batt)}
.brow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:10px 0;border-top:1px solid var(--line)}
.brow:first-child{border-top:0}
.lvl{height:10px;background:var(--track);border-radius:6px;overflow:hidden;grid-column:1/-1;margin:4px 0 2px}
.lvl>i{display:block;height:100%;background:var(--batt);border-radius:6px}
.lvl.none>i{background:var(--faint)}
/* the plan chart */
.chart{position:relative;margin:18px 0 4px}
.bars{display:flex;align-items:flex-end;gap:2px;height:72px}
.bars i{flex:1;border-radius:3px 3px 0 0;background:var(--mid);min-width:0}
.bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
.bars i.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
.lane b{position:absolute;top:2px;bottom:2px;border-radius:3px;min-width:3px}
.lane .house{background:repeating-linear-gradient(90deg,var(--mid) 0 6px,transparent 6px 9px);top:6px;bottom:6px}
.lane .grid{background:var(--accent)}
.lane .give{background:repeating-linear-gradient(135deg,var(--house) 0 4px,color-mix(in srgb,var(--house) 55%,var(--card)) 4px 7px)}
.lane .cycle{background:repeating-linear-gradient(90deg,var(--warn) 0 3px,var(--warn-bg) 3px 6px)}
.lane .peak{background:var(--bad)}
.lanelab{font-size:12px;color:var(--muted);margin-top:6px}
.nowline{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
.nowline span{position:absolute;top:-15px;left:-10px;font-size:10px}
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:4px}
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted);margin-top:8px}
.legend span{display:inline-flex;align-items:center;gap:5px}
.legend i{width:14px;height:10px;border-radius:2px;display:inline-block}
.legend i.house{background:repeating-linear-gradient(90deg,var(--mid) 0 4px,transparent 4px 6px)}
ul.plain{list-style:none;margin:8px 0 0;padding:0}
ul.plain li{padding:6px 0;border-top:1px solid var(--line)}
ul.plain li:first-child{border-top:0}
.meter{height:12px;background:var(--track);border-radius:6px;overflow:hidden;margin:8px 0 4px}
.meter i{display:block;height:100%;background:var(--accent)}
.meter i.warn{background:var(--warn)} .meter i.bad{background:var(--bad)}
.alert{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)}
.alert:first-child{border-top:0}
.sev{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:13px;font-weight:800}
.sev.warning{background:var(--warn-bg);color:var(--warn)} .sev.alarm{background:var(--bad-bg);color:var(--bad)} .sev.info{background:var(--info-bg);color:var(--info)} .sev.ok{background:var(--ok-bg);color:var(--ok)}
.alert .t{font-weight:600}
.btn{border:1px solid var(--line);background:var(--card);padding:9px 14px;border-radius:10px;cursor:pointer;min-height:44px}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
.btn.danger{color:var(--bad);border-color:var(--bad)}
.btn:disabled{opacity:.5;cursor:not-allowed}
a.btn{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink)}
details.more{margin-top:12px;border-top:1px solid var(--line)}
details.more>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
table.rows{width:100%;border-collapse:collapse;font-size:13px}
table.rows td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
table.rows td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;padding-left:12px;overflow-wrap:anywhere}
table.rows tr:last-child td{border-bottom:0}
ul.checks{margin:6px 0 0;padding-left:0;list-style:none;font-size:13px}
ul.checks li{margin:3px 0}
.c-ok{color:var(--ok)} .c-warn{color:var(--warn)} .c-bad{color:var(--bad)}
.kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;font-size:14px;margin-top:10px}
.kv dt{color:var(--muted)} .kv dd{margin:0}
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;z-index:30;background:var(--ink);color:var(--bg);padding:10px 14px;border-radius:10px;font-size:14px;max-width:calc(100% - 32px);opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
@media (max-width:640px){#toast{bottom:84px}}
dialog{border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);max-width:min(420px,calc(100% - 32px));padding:18px}
dialog::backdrop{background:rgba(0,0,0,.45)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
.tip{position:fixed;z-index:40;background:var(--ink);color:var(--bg);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:8px;pointer-events:none;max-width:240px;opacity:0;transition:opacity .1s;box-shadow:0 2px 8px rgba(0,0,0,.25);left:0;top:0}
.tip.show{opacity:1}
.tip b{display:block;font-weight:700}
.tip span{display:block}
.chart [data-tip]{cursor:default}
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* UX-13 P2: Settings, the Installer view (mockup version 2) */
.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0}
.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px}
.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere}
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap}
.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)}
.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)}
details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)}
details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap}
details.grp>summary::-webkit-details-marker{display:none}
details.grp>summary .grow{flex:1 1 200px}
details.grp>summary .gdesc{display:block}
details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)}
details.grp[open]>summary .chev{transform:rotate(90deg)}
.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px}
details.grp .body{padding:0 14px 8px}
details.sub{border-top:1px solid var(--line);margin-top:4px}
details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700}
.f{padding:12px 0;border-top:1px solid var(--line);min-width:0}
.part>.f:first-child,.subhead+.f{border-top:0}
.f .lbl{font-weight:600;font-size:14px}
.f .help{font-size:13px;color:var(--muted);margin-top:4px}
.f .ctl{margin-top:8px;min-width:0}
.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start}
.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2}
.f.inline .help,.f.inline .msgs{grid-column:1/-1}
.f.changed>.head .lbl::after{content:" •";color:var(--accent)}
.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere}
.msg .mi{font-weight:800;flex:none}
.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)}
.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)}
.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0}
.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s}
.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s}
.switch[aria-checked="true"]::before{background:var(--accent)}
.switch[aria-checked="true"]::after{left:25px}
.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px}
.swtext{font-size:13px;color:var(--muted)}
select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%}
select.sel{width:100%}
.row>select.sel{width:auto;flex:1 1 200px}
input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums}
input.txt{width:100%}
input:disabled{opacity:.6}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap}
.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)}
.seg button:first-child{border-left:0}
.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px}
.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)}
.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px}
.choice[aria-checked="true"] .rad{border:5px solid var(--accent)}
.slider{display:flex;align-items:center;gap:10px;width:100%}
.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px}
.unit{color:var(--muted);font-size:13px;white-space:nowrap}
.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center}
.ent-btn .grow{min-width:0}
.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden}
.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px}
.ent-note{padding:6px 10px}
.ent-list{max-height:240px;overflow:auto}
.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px}
.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)}
.ent-opt .grow{min-width:0}
.ent-opt .grow>div{overflow-wrap:anywhere}
.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap}
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
.elist>.btn{margin-top:8px}
.plist .prow{display:grid;gap:6px}
.plist .pf{display:flex;flex-direction:column;gap:2px}
.plist .pf>select.sel{width:100%}
input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
.inv>.f:nth-child(2){border-top:0}
.invs>.btn{margin:4px 0 8px}
#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15}
#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)}
#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)}
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
@media (max-width:640px){#savebar{bottom:62px}}
#s-set{padding-bottom:80px}
/* UX-13 P4: the setup wizard */
.steps{display:flex;gap:4px;margin:4px 0 12px}
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
.msg.ok{background:var(--ok-bg);color:var(--ok)}
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
.kettle .row{margin-top:8px}
/* UX-13 P3: the Batteries screen and the Simple settings view */
.bcard .hd{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.bcard .name{font-weight:700;font-size:16px}
.bcard .modebox .lbl{display:block;margin-bottom:4px}
.bcard .modebox .help{font-size:13px;color:var(--muted);margin-top:4px}
.bcard.off .modebox{opacity:.6}
.bcard .kv dd{overflow-wrap:anywhere}
.switch:disabled{opacity:.45;cursor:not-allowed}
select.sel:disabled{opacity:.6}
.simple .card>.row{margin-bottom:4px}
.vhide{display:none!important}
.simple .card>.f{border-top:0;padding-top:0}
.simple .f>.head{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.simple .card .lbl{font-weight:600;font-size:15px}
.simple .inv .hd{border-bottom:0}
.simple .inv .f{border-top:0;padding-top:0}
+524
View File
@@ -0,0 +1,524 @@
"use strict";
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
external files. Every URL is relative, because ingress serves the add-on
under a generated path prefix. Every word comes from app/ui_text (fixed
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
is checked in Python. Data is always set as text, never as HTML. */
(function () {
// P5: English or Dutch, by the browser's language. A word missing in Dutch
// falls back to English, key by key (app/uitext.py does the same).
const ALL = JSON.parse(document.getElementById("ui-text").textContent);
const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en";
const merge = (a, b) => {
if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a;
const out = Object.assign({}, a);
for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k];
return out;
};
const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]);
document.documentElement.lang = LANG;
const T = WORDS.home;
const $ = (s) => document.querySelector(s);
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
function h(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const k in attrs || {}) {
const v = attrs[k];
if (v === false || v == null) continue;
if (k === "class") e.className = v;
else if (k === "style") e.style.cssText = v;
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v === true ? "" : v);
}
for (const c of kids.flat()) {
if (c == null || c === false) continue;
e.append(c.nodeType ? c : document.createTextNode(String(c)));
}
return e;
}
const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x);
const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1));
const eur = (v) => "€" + dec(Number(v).toFixed(2));
// Fixed labels from ui_text.
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
let S = null; // the last /status
let busy = false;
/* ---------------- toast and confirm dialog ---------------- */
function toast(text) {
const e = $("#toast"); e.textContent = text; e.classList.add("show");
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
}
let onYes = null;
// `key` names Home's words in ui_text, or is {name, description, yes}.
function ask(key, fn) {
const w = typeof key === "string" ? t("dialog." + key) : key;
$("#dlg-t").textContent = w.name;
$("#dlg-b").textContent = w.description;
$("#dlg-yes").textContent = w.yes;
onYes = fn;
const d = $("#dlg");
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
}
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
/* ---------------- data ---------------- */
async function getStatus() {
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
if (!r.ok) throw new Error("status " + r.status);
return r.json();
}
async function act(what) {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what }) });
if (!r.ok) throw new Error("action " + r.status);
return r.json();
}
// `force`: redraw the battery cards even under a focused mode list, after
// the person used it (P3).
async function refresh(force) {
if (busy) return;
busy = true;
try { S = await getStatus(); render(force === true); }
catch (e) { offline(); }
finally { busy = false; }
}
/* ---------------- shell ---------------- */
function banner(el, level, title, sub, role) {
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, sub ? h("small", {}, sub) : null)));
}
function offline() {
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render(force) {
renderPill();
const structured = S.now && S.plan && !S.home_error;
// UX-15: setup-only mode commands nothing and has no control to offer.
const setup = S.setup_only === true;
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
renderControl(structured, setup);
renderDetails(!structured && !setup);
renderBatteries(force);
document.dispatchEvent(new CustomEvent("ux13:status"));
}
/* ---------------- Home: now ---------------- */
function val(id, w, show) {
const e = $(id);
if (w == null || !show) { e.textContent = "–"; return; }
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
}
function levelBar(pct, label) {
const known = pct != null;
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
}
function renderNow() {
const n = S.now;
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
levelBar(b.level_pct, b.name),
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
}
/* ---------------- Home: today's plan ---------------- */
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
["peak", "background:var(--bad)"], ["house", null]];
function tipText(lines) { return lines.filter(Boolean).join(" "); }
function chartItem(tag, cls, style, title, lines) {
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
}
function roving(group) {
const items = [...group.querySelectorAll("[data-tip]")];
items.forEach((el, i) => {
el.tabIndex = i === 0 ? 0 : -1;
el.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
if (d === undefined) return;
e.preventDefault();
const j = Math.max(0, Math.min(items.length - 1, i + d));
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
});
});
}
function renderPlan() {
const p = S.plan;
$("#verdict").textContent = p.verdict;
$("#plan-head").textContent = p.head;
const hours = p.hours || 24;
const bars = $("#bars");
const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name"));
bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
}));
roving(bars);
$("#no-prices").hidden = known.length > 0;
const lanes = $("#lanes");
lanes.replaceChildren();
S.batteries.forEach((b, i) => {
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
for (const r of runs) {
if (r.end_h <= r.start_h) continue;
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
}
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
roving(lane);
});
const nl = $("#nowline");
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
const used = new Set(p.runs.map((r) => r.kind));
known.forEach((b) => used.add(b.kind));
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
const list = [];
S.batteries.forEach((b, i) => {
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
}
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
});
for (const line of p.lines) list.push(h("li", {}, line));
$("#plan-list").replaceChildren(...list);
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
}
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
(function () {
const tip = $("#tip"); let hot = null;
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
function show(el, x, y) {
if (hot && hot !== el) hot.classList.remove("hot");
hot = el; el.classList.add("hot");
const lines = JSON.parse(el.dataset.tip);
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
tip.classList.add("show");
const r = tip.getBoundingClientRect();
let L = x + 12, T2 = y - r.height - 12;
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
if (L < 8) L = 8;
if (T2 < 8) T2 = y + 16;
tip.style.left = L + "px"; tip.style.top = T2 + "px";
}
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
document.addEventListener("pointermove", (e) => {
if (e.pointerType === "touch") return;
const el = target(e);
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
});
document.addEventListener("pointerdown", (e) => {
const el = target(e);
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
else if (!el) hide();
});
document.addEventListener("focusin", (e) => {
const el = target(e);
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
});
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
addEventListener("scroll", hide, { passive: true });
})();
/* ---------------- Home: peak, attention, control, details ---------------- */
function renderPeak() {
const m = S.month_peak;
$("#peak-month").textContent = m.month;
const covered = m.state !== "not_covered";
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
const bar = $("#peak-meter i");
bar.style.width = pct + "%";
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
$("#peak-line").textContent = m.line;
}
function renderAttention() {
const list = S.attention;
const icon = { alarm: "×", warning: "!", info: "i" };
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
h("div", { class: "small" }, a.body),
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
const n = list.filter((a) => a.severity !== "info").length;
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
const badge = $("#attn-badge");
badge.textContent = n; badge.hidden = !n;
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
}
function renderControl(structured, setup) {
const running = structured ? S.control.running : null;
const btn = $("#btn-ctl");
btn.textContent = running === false ? t("control.start") : t("control.stop");
btn.className = "btn" + (running === false ? " primary" : "");
btn.hidden = !!setup;
$("#btn-setup").hidden = !setup;
$("#c-ctl .more").hidden = !!setup;
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
$("#hint").textContent = S.hint || "";
const docs = $("#docs-link");
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
}
function renderDetails(open) {
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
return h("li", { class: cls }, mark + " " + c.text);
}));
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries (UX-13 P3) ---------------- */
// One card per battery. With battery modes on (`mode_key` in /status) each
// card has an On/Off switch and a mode list, sent as `pack_use` and
// `pack_mode` through /action (OPT-11 P1). Selling has no switch while it
// is off (owner decision 2026-09-29). The Now, Today and Why lines are
// homeview.py's sentences. The Simple settings view draws the same switch
// and list into its own card (simpleHost).
const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
let packBusy = false;
let simpleHost = null;
async function packAct(what, b, value) {
if (packBusy) return;
packBusy = true;
try {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what, unit: b.name, value: value }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) toast(j.error || t("toast.failed"));
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
await refresh(true);
}
// MAINT-11: start or stop ONE battery's cycle. Two or more batteries only;
// the add-on refuses a second cycle and says which battery is cycling.
async function cycleAct(what, b) {
if (packBusy) return;
packBusy = true;
try {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what, unit: b.name }) });
const j = await r.json().catch(() => ({}));
const own = j.code && t("batteries.cycle." + j.code);
if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed")));
else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name }));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
await refresh(true);
}
// A button that cannot work is disabled (the add-on still refuses it): Stop
// needs this battery cycling; Start needs no battery cycling and this one on.
function cycleState(b) {
const cycling = (x) => x.maintenance !== "idle";
return { start: !S.batteries.some(cycling) && b.use !== false, abort: cycling(b) };
}
function cycleButtons(b, i) {
const can = cycleState(b);
const ask1 = (key, what) => () => {
const w = t("batteries.cycle." + key);
ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes },
() => cycleAct(what, b));
};
return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" },
h("button", { type: "button", class: "btn", id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")),
h("button", { type: "button", class: "btn danger", id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort")));
}
function onSwitch(b, i, where, lid) {
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
"aria-checked": String(b.use), "aria-labelledby": lid,
onclick: () => packAct("pack_use", b, !b.use) });
return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off")));
}
function modeSelect(b, i, where, lid) {
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
onchange: (e) => packAct("pack_mode", b, e.target.value) },
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
}
function todayLines(b) {
if (!b.today || !b.today.length) return b.today_line || "–";
return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what));
}
function batteryCard(b, i) {
const modes = b.mode_key != null;
const lid = "bname-" + i;
const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %";
const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : "";
return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") },
h("div", { class: "hd" },
h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name),
h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")),
modes ? onSwitch(b, i, "c", lid) : null),
modes ? h("div", { class: "modebox" },
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
modeSelect(b, i, "c", null),
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")),
S.batteries.length > 1 ? cycleButtons(b, i) : null);
}
function simpleRow(b, i) {
const lid = "bsname-" + i;
return h("div", { class: "inv" },
h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)),
h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")),
h("div", { class: "ctl" }, modeSelect(b, i, "s", null)),
h("div", { class: "help" }, b.now + " " + (b.why || ""))));
}
// Rebuild without losing the focused control, and never under an open list
// or a request in flight.
function keepFocus(fn) {
const id = document.activeElement && document.activeElement.id;
fn();
if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); }
}
// The modes-off card's button opens Settings on the Installer view, where
// the "Battery modes" switch is (settings.js sets window.UX13.settingsView).
$("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); });
function renderBatteries(force) {
if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; }
const a = document.activeElement;
if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return;
const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null);
$("#batt-off").hidden = modes;
$("#batt-how").hidden = !modes;
const docs = $("#batt-docs");
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
else $("#batt-banner").replaceChildren();
keepFocus(() => {
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
// OPT-11 P4: the solar forecast line under the cards.
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
if (simpleHost && simpleHost.isConnected) {
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
}
});
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
// nothing when control is already where the person asked it to be.
// One request at a time: a second confirm while the first is in flight
// would read the same state and toggle it back.
let toggling = false;
async function setControl(want) {
if (toggling) return;
toggling = true;
try {
const now = await getStatus();
if (!now.control || now.control.running !== want) await act("auto_toggle");
toast(want ? t("toast.started") : t("toast.stopped"));
} catch (e) { toast(t("toast.failed")); }
finally { toggling = false; }
refresh();
}
$("#btn-ctl").addEventListener("click", () => {
const running = S && S.control ? S.control.running : true;
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
});
async function simple(what) {
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
refresh();
}
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
// Shared with settings.js, which loads after this file.
// `first`: the page opened with no route, so settings.js may open the setup
// wizard on a new install (UX-13 P4).
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
// P3: the Simple settings view's battery card, drawn from /status.
batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); },
status: () => S, kw };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
function select(tab, focus) {
TABS.forEach((x) => {
const on = x === tab;
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
});
if (focus) tab.focus();
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
schedule();
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
}
TABS.forEach((x, i) => {
x.addEventListener("click", () => select(x, false));
x.addEventListener("keydown", (e) => {
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
if (j === undefined) return;
e.preventDefault(); select(TABS[j % TABS.length], true);
});
});
function fromHash() {
const want = (location.hash || "#home").slice(1);
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
}
addEventListener("hashchange", fromHash);
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
let timer = null;
function schedule() {
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
$("#t-batt").getAttribute("aria-selected") === "true");
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
if (!live && timer) { clearInterval(timer); timer = null; }
}
document.addEventListener("visibilitychange", schedule);
fromHash();
refresh();
})();
+176
View File
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Battery add-on</title>
<style>/*APP_CSS*/</style>
</head>
<body>
<header class="top">
<div class="logo" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2M8 10l-2 3h4l-2 3"/></svg></div>
<div class="grow">
<div class="app" data-t="app.name"></div>
<div class="pill" id="pill" aria-live="polite"><span class="dot warn"></span><span>…</span></div>
</div>
</header>
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
</nav>
<main class="wrap">
<section class="screen" id="s-home" role="tabpanel" aria-labelledby="t-home">
<div id="home-banner"></div>
<div class="card" id="c-now">
<div class="row spread"><h2 data-t="now.name"></h2><span class="tiny muted" data-t="now.description"></span></div>
<div class="tiles">
<div class="tile"><div class="lab"><span class="sw-solar" aria-hidden="true">&#9728;</span><span data-t="now.solar"></span></div><div class="val" id="v-solar">–</div><div class="sub" id="sub-solar"></div></div>
<div class="tile"><div class="lab"><span class="sw-house" aria-hidden="true">&#8962;</span><span data-t="now.house"></span></div><div class="val" id="v-house">–</div><div class="sub" id="sub-house"></div></div>
<div class="tile"><div class="lab"><span class="sw-grid" aria-hidden="true">&#8645;</span><span data-t="now.grid"></span></div><div class="val" id="v-grid">–</div><div class="sub" id="sub-grid"></div></div>
<div class="tile"><div class="lab"><span class="sw-batt" aria-hidden="true">&#9646;</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
</div>
<div id="home-batts" style="margin-top:6px"></div>
</div>
<div class="card" id="c-plan">
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
<div class="small" id="plan-head"></div>
<div class="chart" id="chart">
<div class="bars" id="bars" role="group"></div>
<div id="lanes"></div>
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
<div class="legend" id="legend"></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
<h3 class="sr" data-t="plan.list"></h3>
<ul class="plain small" id="plan-list"></ul>
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
</div>
<div class="card" id="c-peak">
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
<div class="tiny muted" id="peak-line"></div>
</div>
<div class="card" id="c-attn">
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
<div id="alerts"></div>
</div>
<div class="card" id="c-ctl">
<h2 data-t="control.name"></h2>
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
<details class="more">
<summary data-t="control.more"></summary>
<div class="row" style="padding-bottom:6px">
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
</div>
</details>
</div>
<details class="card more" id="c-details">
<summary data-t="details.name"></summary>
<table class="rows" id="rows"></table>
<h2 style="margin-top:12px" data-t="details.checks"></h2>
<ul class="checks" id="checks"></ul>
</details>
</section>
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="batt-banner"></div>
<div class="card" id="batt-off" hidden>
<h2 data-t="batteries.modes_off.name"></h2>
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
<div id="bcards"></div>
<p class="small muted" id="batt-solar" hidden></p>
<details class="grp" id="batt-how" hidden>
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">&#8250;</span></summary>
<div class="body small">
<p data-t="batteries.how.solar_first.description"></p>
<p data-t="batteries.how.leftover_solar.description"></p>
<p data-t="batteries.how.off.description"></p>
<p class="muted" data-t="batteries.how.rules.description"></p>
<p><a id="batt-docs" hidden target="_top" rel="noopener" data-t="control.docs"></a></p>
</div>
</details>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="row spread" style="margin-bottom:6px">
<h2 style="margin:0" data-s="name"></h2>
<a class="small" href="config" id="set-classic" data-s="classic"></a>
</div>
<div class="row" style="margin-bottom:8px">
<div class="seg" role="radiogroup" id="set-view" data-s-label="view.name">
<button type="button" role="radio" aria-checked="true" data-v="simple" data-s="view.simple"></button>
<button type="button" role="radio" aria-checked="false" data-v="installer" tabindex="-1" data-s="view.installer"></button>
</div>
<span class="tiny muted grow" id="set-desc"></span>
</div>
<div id="set-banner" aria-live="polite"></div>
<div id="simple" class="simple" hidden></div>
<div id="set-body">
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
<div id="groups"><p class="small muted" data-s="loading"></p></div>
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
<p class="tiny muted" data-s="footer.description"></p>
</div>
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card" id="wz">
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
<div class="steps" id="wz-steps" aria-hidden="true"></div>
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
<div id="wz-banner" aria-live="polite"></div>
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
<div class="row spread" id="wz-nav" style="margin-top:14px">
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
</div>
</div>
<p class="tiny muted" data-s="setup.description"></p>
</section>
</main>
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
<h2 id="dlg-t"></h2>
<p class="small" id="dlg-b"></p>
<div class="row" style="justify-content:flex-end">
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="savebar" role="region" hidden><div class="in">
<span class="grow" id="sb-text" aria-live="polite"></span>
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
<script>/*SETTINGS_JS*/</script>
</body>
</html>
+941
View File
@@ -0,0 +1,941 @@
"use strict";
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
from app/uimodel.py), so this file names no option key: keys, labels,
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML.
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
(function () {
const ROOT = window.UX13.words; // English or Dutch (app.js)
const W = ROOT.settings;
const { h, ask, toast, maint, lang } = window.UX13;
const $ = (s) => document.querySelector(s);
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
$("#savebar").setAttribute("aria-label", W.savebar.label);
let M = null; // the last /config/model
let V = null; // the page model being edited
let ORIG = null; // the page model as stored
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
let MSG = []; // [{field, card, tier, text}] from the add-on
let DEVICE = "";
let LIST = null; // the inverter list field
let FILLED = {}; // field key -> the preset field that fills it
let view = null; // "settings" or "setup": the tab drawing V
let STEPS = {}; // wizard step -> refusals that block it
// P3: "simple" (the default, for home owners) or "installer". Remembered in
// this browser only; a blocked storage just means Simple each time.
let mode = "simple";
try { if (localStorage.getItem("ux13.settings.view") === "installer") mode = "installer"; } catch (e) { /* no storage */ }
let uid = 0;
const ROWS = [];
const OPEN = new Set();
const UNLOCKED = new Set();
/* ---------------- the page model ---------------- */
const sub = (f) => f.key.slice(LIST.key.length + 1);
function valueIn(model, f, card) {
if (card == null) return model[f.key];
const c = (model[LIST.key] || [])[card];
return c ? c[sub(f)] : undefined;
}
const get = (f, card) => valueIn(V, f, card);
function visible(f) {
return (f.show || []).every(([name, op, want]) => {
const have = V[name];
if (op === "==") return same(have, want);
if (op === "!=") return !same(have, want);
if (op === "in") return want.some((x) => same(x, have));
if (have == null || have === "" || isNaN(+have)) return false;
return op === ">" ? +have > want : +have < want;
});
}
function put(f, card, v, redraw) {
if (card == null) V[f.key] = v;
else V[LIST.key][card][sub(f)] = v;
if (f.mirror) {
const m = findField(f.mirror);
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
}
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
if (redraw || f.fills) redrawView(); else paint();
queueValidate();
}
function findField(key) {
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.key === key) return f;
for (const s of f.fields || []) if (s.key === key) return s;
}
return null;
}
/* ---------------- numbers: stored units <-> shown units ---------------- */
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
function shown(f, v) {
if (v == null || v === "") return "";
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
}
function stored(f, x) {
const st = f.step || (f.type === "integer" ? 1 : 0);
let v = x * (f.scale || 1);
if (st) v = Math.round(v / st) * st;
if (f.min != null) v = Math.max(f.min, v);
if (f.max != null) v = Math.min(f.max, v);
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
}
/* ---------------- controls ---------------- */
// A choice the page can only keep stays offered while it is the stored value.
function choicesOf(f, card) {
const cur = get(f, card), was = valueIn(ORIG, f, card);
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
return out;
}
function toggle(f, card, lid) {
const on = !!get(f, card);
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
b.addEventListener("click", () => {
const n = b.getAttribute("aria-checked") !== "true";
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
put(f, card, n);
});
return h("span", { class: "swrap" }, b, txt);
}
function radios(f, card, lid, cards) {
const cur = get(f, card);
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
const items = choicesOf(f, card);
const buttons = items.map(([v, label], i) => {
const on = same(v, cur);
const hint = cards && f.hints ? f.hints[v] : "";
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
b.addEventListener("click", () => put(f, card, v, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + items.length) % items.length;
put(f, card, items[j][0], true);
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
if (again) again.focus();
});
return b;
});
g.dataset.rk = f.key + "|" + card;
g.append(...buttons);
return g;
}
function select(f, card, lid) {
const cur = get(f, card);
const s = h("select", { class: "sel", "aria-labelledby": lid });
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
const preset = f.presets && f.presets[cur];
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
}
function slider(f, card, lid) {
if (f.min == null || f.max == null) return number(f, card, lid);
const sc = f.scale || 1;
const st = f.step || (f.type === "integer" ? 1 : 0.01);
const v = get(f, card);
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
n.addEventListener("change", () => {
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
});
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
}
function number(f, card, lid) {
const sc = f.scale || 1;
const by = FILLED[f.key];
const preset = by && by.presets[V[by.key]];
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
n.addEventListener("input", () => {
if (n.value === "") { if (f.optional) put(f, card, null); return; }
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
});
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
}
function text(f, card, lid) {
const v = get(f, card);
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
t.addEventListener("input", () => put(f, card, t.value));
return t;
}
function locked(f, card, lid) {
const box = h("div", {});
const id = f.key + "#" + card;
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
if (UNLOCKED.has(id)) { open(); return box; }
const v = get(f, card);
box.append(h("div", { class: "row" },
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
h("span", { class: "chip" }, W.locked),
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
return box;
}
/* entity picker: search, the matching kind first, the live reading */
const ENT = {};
function entities(kind) {
const now = Date.now();
const e = ENT[kind];
if (e && now - e.at < 30000) return e.p;
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
.catch(() => ({ available: false, entities: [] }));
ENT[kind] = { at: now, p };
return p;
}
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
function picker(f, lid, value, pick) {
let data = null, all = false;
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
const pop = h("div", { class: "ent-pop", hidden: true });
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
const note = h("div", { class: "tiny muted ent-note" });
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
const allBox = h("label", { class: "tiny muted ent-all" },
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
function drawBtn() {
const id = value();
const e = data && data.entities.find((x) => x.entity_id === id);
btn.replaceChildren();
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
}
function opt(id, name, val) {
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
}
function fill() {
const typed = q.value.trim();
const t = typed.toLowerCase();
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
note.textContent = data && data.text ? data.text : "";
note.hidden = !note.textContent;
}
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
btn.addEventListener("click", () => {
if (!pop.hidden) { close(); return; }
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
setTimeout(() => q.focus(), 0);
});
q.addEventListener("input", fill);
pop.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const opts = [...list.querySelectorAll(".ent-opt")];
const i = opts.indexOf(document.activeElement);
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
});
pop.append(q, note, list, allBox);
drawBtn();
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
return h("div", { class: "ent" }, btn, pop,
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
}
function entity(f, card, lid) {
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
}
function entityList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((_id, i) => {
box.append(h("div", { class: "erow" },
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
return box;
}
function solarList(f, card, lid) {
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist" });
arr().forEach((row, i) => {
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
box.append(h("div", { class: "erow" },
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
return box;
}
// P5: time windows, each a start, an end, its days and a price. The add-on
// builds the tariff from the rows and names the first overlap or gap.
function periodList(f, card) {
const P = W.periods;
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist plist" });
if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description);
arr().forEach((row, i) => {
const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); };
const say = (what) => P.window + " " + (i + 1) + ", " + what;
const time = (k) => {
const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00",
value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) });
e.addEventListener("input", () => set(k, e.value.trim()));
return e;
};
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
box.append(h("div", { class: "erow prow" },
h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)),
h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")),
cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))),
cell(P.days, days)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => {
const a = arr(), last = a[a.length - 1];
put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true);
} }, "+ " + P.add));
return box;
}
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList,
period_list: periodList };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
if (view === "setup") return ""; // the wizard shows no keys
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
}
function fieldRow(f, card, gid) {
if (f.control === "inverter_list") return cardList(f, gid);
const lid = "sf" + (++uid);
const keys = gid === "simple" ? "" : keyText(f, card); // Simple shows no keys
const msg = h("div", { class: "msgs" });
const make = CONTROLS[f.control] || text;
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""),
keys ? h("div", { class: "key" }, keys) : null),
h("div", { class: "ctl" }, make(f, card, lid)),
f.help ? h("div", { class: "help" }, f.help) : null, msg);
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
return row;
}
function blankCard(f, first) {
const out = {};
for (const s of f.fields) {
const k = sub(s);
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
}
return out;
}
function cardList(f, gid) {
const wrap = h("div", { class: "invs", "data-key": f.key });
const msg = h("div", { class: "msgs" });
const cards = V[f.key];
if (!Array.isArray(cards)) {
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
} else {
cards.forEach((c, i) => {
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
// Back to one card, the stored shape returns: a flat inverter stays flat.
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
put(f, null, V[f.key], true);
} }, W.remove) : null));
for (const s of f.fields) box.append(fieldRow(s, i, gid));
wrap.append(box);
});
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
put(f, null, V[f.key], true);
} }, "+ " + W.add_inverter));
}
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
return wrap;
}
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
function render() {
const host = $("#groups");
const y = window.scrollY;
host.replaceChildren();
ROWS.length = 0;
for (const g of M.layout.groups) {
const d = h("details", { class: "grp", "data-g": g.id },
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
d.open = OPEN.has(g.id);
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
const body = h("div", { class: "body" });
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
for (const p of g.parts) {
const pid = g.id + "." + p.id;
let box;
if (p.collapsed) {
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
box.open = OPEN.has(pid);
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
} else {
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
}
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
body.append(box);
}
if (g.id === "maintenance") {
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
h("div", { class: "row" },
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
}
d.append(body);
host.append(d);
}
renderSimple();
paint();
window.scrollTo(0, y);
}
/* ---------------- Settings, Simple (P3) ---------------- */
// The fields the layout marks `simple`, each in its own card with when it
// applies, and the batteries' switches and modes (runtime state, applied at
// once) drawn by app.js from /status. The Installer rows stay built but
// hidden, so the save bar counts every unsaved change in either view.
const chip = (applies) => h("span", { class: "chip " + (applies === "now" ? "now" : "rs") }, ROOT.applies[applies].name);
// A note with {kw} is filled from /status (app.js sends ux13:status).
function fillNotes() {
const st = window.UX13.status();
const mp = st && st.month_peak;
document.querySelectorAll("#simple [data-kw-note]").forEach((e) => {
const ok = mp && mp.peak_w != null;
e.hidden = !ok;
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
});
}
// OPT-11 P4: the solar forecast card shows the add-on's forecast line.
function fillSolar() {
const st = window.UX13.status();
const line = st && st.solar ? st.solar.line : "";
document.querySelectorAll("#simple [data-solar-note]").forEach((e) => {
e.textContent = line || "";
e.hidden = !line;
});
}
document.addEventListener("ux13:status", fillNotes);
document.addEventListener("ux13:status", fillSolar);
function simpleField(f) {
const row = fieldRow(f, null, "simple");
row.querySelector(".head").append(chip(f.applies));
const n = f.note && W.simple.note[f.note];
if (n && n.description.includes("{kw}")) {
row.append(h("div", { class: "small muted", "data-kw-note": n.description, hidden: true }));
} else if (n) {
// A plain note replaces the option's installer help on this view.
const help = row.querySelector(".help");
if (help) help.textContent = n.description;
}
if (f.note === "forecast") row.append(h("div", { class: "small muted", "data-solar-note": "", hidden: true }));
return h("div", { class: "card" }, row);
}
function renderSimple() {
const host = $("#simple");
host.replaceChildren();
if (mode !== "simple") return;
const cards = allFields().filter((f) => f.simple).map(simpleField);
const batts = h("div", {});
cards.splice(Math.min(1, cards.length), 0, h("div", { class: "card" },
h("div", { class: "row spread" }, h("div", { class: "lbl" }, W.simple.batteries.name), chip("now")), batts));
host.append(...cards, h("p", { class: "small muted", style: "margin:4px 2px" }, W.simple.more.description + " ",
h("button", { type: "button", class: "btn link", onclick: () => setMode("installer", true) }, W.simple.installer)));
window.UX13.batteries(batts);
fillNotes();
}
// `remember`: only the person's own pick of a view is kept for next time;
// a jump to Installer (a link, a setting to fix) is not.
function setMode(v, redraw, remember) {
mode = v;
if (remember) try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ }
document.querySelectorAll("#set-view [role=radio]").forEach((b) => {
const on = b.dataset.v === v;
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
});
$("#set-desc").textContent = v === "simple" ? W.simple.description : W.description;
$("#simple").hidden = v !== "simple";
["#set-search", "#groups", "#set-nomatch"].forEach((id) => { $(id).classList.toggle("vhide", v === "simple"); });
if (redraw && M && view === "settings") render();
}
document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => {
b.addEventListener("click", () => setMode(b.dataset.v, true, true));
b.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = all[(i + d + all.length) % all.length];
setMode(j.dataset.v, true, true); j.focus();
});
});
setMode(mode, false);
window.UX13.settingsView = (v) => setMode(v, true);
/* ---------------- messages, visibility, search, badges ---------------- */
const ICON = { bad: "×", warn: "!", info: "i" };
const RANK = { bad: 0, warn: 1, info: 2 };
function mine(r, m) {
if (m.field !== r.f.key) return false;
if (r.list || r.card == null) return true;
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = view === "setup" || mode === "simple" ? "" : $("#set-search").value.trim().toLowerCase();
// A message counts as shown only beside a row of the view on screen.
const onScreen = (r) => view === "setup" || (mode === "simple") === (r.gid === "simple");
const placed = new Set();
for (const r of ROWS) {
r.visible = visible(r.f);
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible && onScreen(r)) placed.add(i); return hit; })
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
r.tiers = new Set(msgs.map((m) => m.tier));
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
// A card list counts as one change when cards were added or removed;
// an edit inside a card counts on its own row.
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
r.el.classList.toggle("changed", r.changed && !r.list);
}
// Search: a row shows when it is visible and matches. A card list shows
// while any of its rows does.
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
let any = false;
document.querySelectorAll("#groups details.grp").forEach((d) => {
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
d.querySelectorAll(".part").forEach((p) => {
const inside = rows.filter((r) => p.contains(r.el));
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
});
const hit = rows.some((r) => !r.el.hidden);
any = any || hit;
d.hidden = !!q && !hit;
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
const shownRows = rows.filter((r) => r.visible && !r.list);
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
const changed = rows.filter((r) => r.changed).length;
d.querySelector(".gbadge").replaceChildren(...[
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
});
$("#set-nomatch").hidden = !q || any;
// A sentence shown beside any field is not repeated here.
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
if (DEVICE) other.push({ tier: "info", text: DEVICE });
$("#set-other").hidden = !other.length;
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
savebar();
}
function dirtyCount() {
const seen = new Set();
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
return seen.size;
}
function savebar() {
const n = V ? dirtyCount() : 0;
$("#savebar").hidden = !(view === "settings" && n);
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
}
$("#set-search").addEventListener("input", () => { if (V) paint(); });
/* ---------------- talking to the add-on ---------------- */
let vtimer = null, vseq = 0;
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
// or null when it failed or a newer check overtook it.
async function validate(extra) {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
const b = await r.json();
if (seq !== vseq) return null;
MSG = b.messages || [];
DEVICE = b.device_note || "";
STEPS = b.steps || {};
paint();
return b;
} catch (e) { return null; /* the next change tries again */ }
}
function banner(level, title, sub, button, onClick) {
const host = $("#set-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
function restart() {
ask(W.dialog, async () => {
try {
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
} catch (e) { /* the add-on stops answering while it restarts */ }
toast(W.toast.restarting.description);
});
}
async function load(after) {
let m;
try {
const r = await fetch("config/model?lang=" + lang, { cache: "no-store" });
if (!r.ok) throw new Error("model " + r.status);
m = await r.json();
if (!m.layout || !m.ui) throw new Error("no layout");
} catch (e) {
$("#groups").replaceChildren();
$("#set-search").hidden = true;
banner("bad", W.load_failed.name, W.load_failed.description);
return;
}
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
UNLOCKED.clear();
$("#set-search").hidden = false;
FILLED = {};
LIST = null;
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
if (f.fills) FILLED[f.fills] = f;
if (f.control === "inverter_list") LIST = f;
}
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
else banner();
if (view === "setup") drawStep(); else render();
validate();
}
function firstBad() {
if (view === "settings" && mode === "simple" && !ROWS.some((x) => x.gid === "simple" && x.tiers && x.tiers.has("bad") && x.visible)
&& ROWS.some((x) => x.tiers && x.tiers.has("bad") && x.visible)) setMode("installer", true);
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible && (view !== "settings" || (mode === "simple") === (x.gid === "simple")));
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
r.el.scrollIntoView({ block: "center" });
const input = r.el.querySelector("input,select,button");
if (input) input.focus({ preventScroll: true });
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves. `base` is
// the model the page opened: only what changed since then is written, onto
// what is stored now (UX-15 F2).
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
}
// The settings another surface changed (not this page's own edits).
function staleText(b) {
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
}
async function save() {
const btn = $("#sb-save");
btn.disabled = true; btn.textContent = W.savebar.saving;
try {
clearTimeout(vtimer);
await validate();
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
const { r, b } = await postSave();
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
banner("bad", W.stale.name, staleText(b));
return;
}
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
toast(W.toast.failed);
} catch (e) { toast(W.toast.failed); }
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
}
$("#sb-save").addEventListener("click", save);
// Discard reloads what is stored: after a stale save ORIG is no longer it.
$("#sb-discard").addEventListener("click", async () => {
await load(); toast(W.toast.discarded);
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
/* ---------------- the setup wizard (P4) ---------------- */
const WS = W.setup;
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
function redrawView() { if (view === "setup") drawStep(); else render(); }
function allFields() {
const out = [];
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
return out;
}
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
function watts(w, signed) {
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return (signed && w < 0 ? "-" : "") + n + " W";
}
function line(tier, text) {
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
}
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
// uipage.kettle() (test_setup.py grades that one) on the page.
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
function kettleOutcome(src, before, after) {
const d = after - before;
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
const o = src[d > 0 ? "up" : "down"];
return { say: o.say, sets: o.sets, d };
}
function kettleOk() {
const src = kettleSource();
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
}
async function readMeter(src) {
let total = 0;
for (const [key, sign] of src.read) {
if (!V[key]) return null;
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
if (!r || r.watts == null) return null;
total += sign * r.watts;
}
return total;
}
function kettleBox() {
const src = kettleSource();
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
const sig = kettleSig(src);
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
// A skip after the reading went down still says it went down (UX-15).
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
const busy = (b) => { b.disabled = true; };
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
read.addEventListener("click", async () => {
busy(read);
const sig = kettleSig(src); // the entity may have changed since drawing
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
const w = await readMeter(src);
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
else {
const o = kettleOutcome(src, k.before, w);
// `wrong`: the reading went the way no option can fix. A skip after
// that keeps saying so on the last step (review R2).
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
}
drawStep();
});
const row = h("div", { class: "row" }, read);
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
} }, WS.kettle.skip));
box.append(row);
return box;
}
// Step 6: the shadow switch, the checks, and Save and restart.
// The shadow switch goes only on the model the wizard posts, never into V,
// which the Installer view saves too (review R3).
function shadowValue() {
const s = M.setup.shadow;
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
}
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
function shadowRow() {
const lid = "wzs";
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
return h("div", { class: "f inline" },
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
h("div", { class: "ctl" }, b));
}
async function runChecks() {
const list = $("#wz-checks");
if (list) list.replaceChildren(line("info", WS.check.running.description));
clearTimeout(vtimer);
const b = await validate({ setup: true, model: posted() });
WZ.checked = b;
if (!list || !list.isConnected) return b;
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
const items = [], seen = new Set();
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
const bad = (b.messages || []).filter((m) => m.tier === "bad");
if (!bad.length) add("ok", WS.check.pass.description);
for (const m of bad) add("bad", m.text);
for (const m of b.setup || []) add(m.tier, m.text);
if (b.device_note) add("info", b.device_note);
const src = kettleSource();
if (!src) add("info", WS.kettle.self.description);
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
else if (kettleOk()) add("ok", WS.kettle.tested.description);
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
WZ.blocked = items.some(([t]) => t === "bad");
return b;
}
function finalBox() {
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
setTimeout(runChecks, 0);
return [h("div", { class: "subhead" }, WS.check.name),
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
shadowRow()];
}
function wzBanner(level, title, sub, button, onClick) {
const host = $("#wz-banner");
if (!title) { host.replaceChildren(); return; }
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
}
async function finish() {
const b = await runChecks();
if (!b) { toast(WS.check.failed.description); return; }
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
const shadow = shadowValue() === M.setup.shadow.on;
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
ask(dlg, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
const { r, b: body } = res;
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
if (r.status === 422) {
MSG = body.messages || []; STEPS = body.steps || {};
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
if (first) WZ.step = first;
drawStep(); toast(W.toast.blocked.description); firstBad();
return;
}
if (!r.ok) { toast(W.toast.failed); return; }
let restarted = true;
try {
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
restarted = rr.ok;
} catch (e) { /* the add-on stops answering while it restarts */ }
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
drawStep();
if (!restarted) await load("saved"); // still running: read what is stored
});
}
function drawDone() {
const w = WS[WZ.done];
$("#wz-count").textContent = "";
$("#wz-steps").replaceChildren();
$("#wz-desc").textContent = "";
$("#wz-nav").hidden = true;
ROWS.length = 0;
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
else wzBanner("ok", w.name, w.description);
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
}
function drawStep() {
if (!M) return;
if (WZ.done) { drawDone(); return; }
const S = M.setup, n = WZ.step, words = WS.steps[n];
$("#wz-nav").hidden = false;
$("#wz-title").textContent = words.name;
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
$("#wz-desc").textContent = words.description;
ROWS.length = 0;
const body = [];
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
if (n === 1) body.push(kettleBox());
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
if (n === S.final) body.push(...finalBox());
$("#wz-body").replaceChildren(...body);
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
paint();
}
$("#wz-back").addEventListener("click", () => {
if (WZ.step === 1) { location.hash = "#home"; return; }
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
});
$("#wz-next").addEventListener("click", async () => {
if (!M) return;
const btn = $("#wz-next");
btn.disabled = true;
try {
const n = WZ.step;
if (n === M.setup.final) { await finish(); return; }
clearTimeout(vtimer);
const b = await validate();
if (!b) { toast(W.toast.failed); return; }
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
} finally { btn.disabled = false; }
});
document.addEventListener("ux13:tab", (e) => {
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
});
// app.js chose the first tab before this file ran.
const sel = document.querySelector(".tab[aria-selected=true]");
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
// First run: opened with no route, on a site the add-on says is new
// (uipage.setup_needed), the page goes to Setup by itself.
else if (window.UX13.first) {
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
}
})();
+40 -13
View File
@@ -1130,13 +1130,28 @@ class TariffPlan:
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
# UX-10: the spacing in words, and no "not final yet": a plan is
# provisional exactly when it holds an estimated price, which the
# count already says.
gaps = self.spacings_s
return (f"{what} from {tag}, "
f"{self.n_points} prices over {self.hours} hours, "
f"{self.n_firm} published and {self.n_carried} estimated"
+ (f", {'+'.join(str(g) for g in gaps)} s apart"
if gaps else "")
+ (", not final yet" if self.provisional else ""))
+ (f", {spacing_words(gaps)}" if gaps else ""))
def spacing_words(gaps) -> str:
"""`(900, 3600)` -> `one every 15 minutes or every hour`."""
def one(s):
s = int(s)
if s == 3600:
return "every hour"
if s % 3600 == 0:
return f"every {s // 3600} hours"
if s % 60 == 0:
return f"every {s // 60} minutes"
return f"every {s} seconds"
return "one " + " or ".join(one(g) for g in gaps)
def _whole_hours(points) -> int:
@@ -1206,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
if profile is None:
return TariffPlan(
PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no "
"arbitrage. Self-consumption, peak shaving and reserve "
"management are unaffected"),
"no fixed tariff is set, so the plan is estimated and there "
"is no trading. Self-consumption, peak limiting and the "
"reserve are not affected"),
None, "none")
points = profile.series(since, until, provisional=provisional)
return TariffPlan(
@@ -1217,6 +1232,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
profile, "fallback" if provisional else "static")
def static_gated(opts: dict, now: datetime) -> bool:
"""MAINT-13: is a fixed tariff set up, so that a cheap window can hold a
charge back? False for no profile or one that cannot be used."""
try:
return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now).has_prices)
except ProfileInvalid:
return False
def static_status(opts: dict, now: datetime):
"""(cheap, words) for the status page and for main.py's cheap-window flag.
@@ -1338,16 +1363,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
try:
profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable "
"(%s), so there is no tariff at all", e)
_LOG.warning("The price feed is down and the fixed tariff "
"(static_profile) cannot be used either (%s), so there is "
"no tariff at all.", e)
profile = None
if profile is None:
_LOG.warning("%s, and no static_profile is set to fall back to, so "
"prices are provisional and trading is off. Peak "
"limiting and home use go on.", read.reason)
_LOG.warning("The price feed failed and no fixed tariff is set, so "
"trading is off, while peak limiting and home use go on. "
"%s.", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
"provisional, and trading is off.", read.reason)
_LOG.warning("The price feed failed, so the fixed tariff is used "
"instead, marked as estimated, and trading is off. %s.",
read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
+42 -25
View File
@@ -94,6 +94,7 @@ DEFAULTS = {
"phase_started": None,
"last_completed": None,
"last_start_attempt": None,
"last_failed": None,
"sign_tripped": False,
"sign_reason": "",
"circ_tripped": False,
@@ -171,7 +172,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version"
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
# SAFETY-02 pair.
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
"last_start_attempt")
"last_start_attempt", "last_failed")
def unit_key(unit: str, key: str) -> str:
@@ -222,6 +223,8 @@ class MigrationReport:
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
# The inverter list the moves were graded against, for `explain()`.
self.roster = []
@property
def ok(self) -> bool:
@@ -237,19 +240,27 @@ class MigrationReport:
# the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them
# changes when a second inverter is declared.
return self.skipped or (f"store already at schema "
f"{self.from_version}; no "
f"per-inverter migration needed")
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
f"namespaces: " + ", ".join(sorted(self.moved)))
return self.skipped or (f"the saved state is already at version "
f"{self.from_version}, so nothing moves")
# UX-10: plain sentences (test_messages.py part 6). Each problem is
# named by its key and what went wrong, and `problems`, `lost` and
# `misdirected` keep their full notes for the checks that read them.
n = len(self.moved)
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
f"per inverter: " + ", ".join(sorted(self.moved)) if n
else "moved nothing")
if self.kept:
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
if self.problems:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
out += ". Already in place: " + ", ".join(sorted(self.kept))
def keys(notes):
return ", ".join(note.split(" ", 1)[0] for note in notes)
other = [n for n in self.problems
if n not in self.lost and n not in self.misdirected]
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
[f"moved under an inverter not in the list {self.roster!r}: "
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
[f"read back differently {keys(other)}"] if other else [])
if parts:
out += ". Problems: " + ", and ".join(parts)
return out
@@ -436,6 +447,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if roster is None:
return
names = [str(n) for n in roster]
rep.roster = names
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
#
@@ -469,8 +481,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
note = (f"{target} was moved under {unit!r}, which is not in the "
f"inverter list {names!r}, so nothing reads it")
rep.problems.append(note)
rep.misdirected.append(note)
@@ -536,14 +548,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
rep.skipped = ("one inverter, so it is read where it is and nothing "
"moves")
return rep
if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema "
f"{rep.from_version}; written by a multi-inverter "
f"version and not touched")
rep.skipped = (f"already at version {rep.from_version}, written for "
f"several inverters, so it is left as it is")
return rep
rep.ran = True
@@ -567,15 +577,21 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
# the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
_LOG.error("Moving the maintenance history to one part per inverter "
"did not work, so it is not marked done: %s", rep.explain())
return rep
STATE_PATH = "/data/state.json"
class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
def __init__(self, path: str | None = None):
self.path = path or STATE_PATH
self.data = dict(DEFAULTS)
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
self.fresh = False
self.load()
def load(self) -> None:
@@ -589,6 +605,7 @@ class Store:
self.data.get("phase"), self.data.get("last_completed"),
self.data.get("sign_tripped"))
except FileNotFoundError:
self.fresh = True
_LOG.info("no saved state, starting fresh")
except (json.JSONDecodeError, OSError) as err:
# A corrupt state file must not stop the controller: losing the
+52 -14
View File
@@ -114,6 +114,8 @@ import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo
from . import messages
_LOG = logging.getLogger("goodwe.tariff")
QUARTER_S = 900
@@ -339,8 +341,8 @@ class PeakStore:
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong. A store that OPENED and then broke is
# the error case, and it is logged as one below.
_LOG.info("no peak store: %s - the monthly peak will read "
"NOT_OBSERVED", self.last_error)
_LOG.info("No peak store (%s), so the month peak cannot be read "
"and only the peak limit is used.", self.last_error)
return
try:
self._db = sqlite3.connect(self.path, timeout=5.0,
@@ -360,8 +362,9 @@ class PeakStore:
# fault must not stop a controller that is regulating a battery. The
# cost of continuing is a peak that reads NOT_OBSERVED, and every
# reader is built to treat that as "do not use me", not as 0 W.
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
_LOG.error("The peak store at %s cannot be used (%s), so only the "
"peak limit is used. The month peak is unknown, not "
"zero.", self.path, err)
def close(self) -> None:
if self._db is not None:
@@ -672,12 +675,12 @@ class PeakTracker:
now = now or datetime.now(timezone.utc)
snap = self.store.load_partial()
if not snap:
self.restore_state = ("fresh: no checkpoint" if self.store.ok
else "fresh: store unreadable")
self.restore_state = ("fresh start, nothing saved" if self.store.ok
else "fresh start, the store cannot be read")
return self.restore_state
block = snap.get("block")
if block is None:
self.restore_state = "fresh: checkpoint had no open block"
self.restore_state = "fresh start, no open 15-minute period saved"
return self.restore_state
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
@@ -686,11 +689,13 @@ class PeakTracker:
# A reshaped accumulator (meter_phases changed) is a different
# measurement, not a resumable one. Say so; do not half-resume.
self.store.clear_partial()
self.restore_state = "fresh: checkpoint does not fit this averager"
self.restore_state = ("fresh start, the saved period does not "
"fit the number of phases")
return self.restore_state
self.restore_state = (
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
f"resumed the open 15-minute period at "
f"{snap.get('elapsed', 0.0):.0f} s of {QUARTER_S} s, with "
f"{snap.get('acc', 0.0) / QUARTER_S:.0f} W counted so far")
return self.restore_state
# The block ended while the add-on was down. Its measured part really
@@ -704,10 +709,14 @@ class PeakTracker:
if snap.get("pp_acc") and snap.get("last_pp") else None))
banked = self.store.record_block(closed, self.policy)
self.store.clear_partial()
# UX-10: plain words and the local time, not ISO and "banked".
self.restore_state = (
f"closed a stale block from {closed.start.isoformat()} at "
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
+ ("banked" if banked else "NOT banked - store unwritable"))
f"closed the 15-minute period from "
f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
f"{closed.offtake_avg_w:.0f} W, partly measured, "
+ ("and saved it" if banked
else "but could not save it, because the store cannot be "
"written"))
return self.restore_state
# -- steady state -------------------------------------------------------
@@ -767,9 +776,14 @@ class PeakTracker:
return r.peak_w / self.cap_w
@staticmethod
def project(averager, now: datetime) -> Projection:
def project(averager, now: datetime, roll: bool = False) -> Projection:
"""End-of-quarter average if the last sample held for the rest of it.
`roll` (OPT-15, battery modes only): when `now` is past the open block
because no sample has come since the boundary, project the quarter
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
Without it that tick reads "no seconds left" and measures nothing.
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
block. `elapsed_w` divides by the seconds actually covered (what the
meter has shown so far); `projected_w` divides by the full 900 s and
@@ -789,6 +803,14 @@ class PeakTracker:
elapsed_wall = max(0.0, (now - start).total_seconds())
elapsed = averager.elapsed_s
acc = averager.partial_ws
if roll and elapsed_wall >= QUARTER_S:
t = now.timestamp()
block = int(t // QUARTER_S) * QUARTER_S
start = datetime.fromtimestamp(block, timezone.utc)
held_end = float(averager.held_t) + float(averager.max_hold_s)
elapsed = max(0.0, min(t, held_end) - block)
elapsed_wall = t - block
acc = max(averager.held_net_w, 0.0) * elapsed
remaining = max(0.0, QUARTER_S - elapsed_wall)
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
@@ -813,6 +835,22 @@ class PeakTracker:
held_w=averager.held_net_w)
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
"""SAFETY-91: `p` past its block's end, before the next block's first
telegram: the next quarter, projected from the held reading (the averager
credits it there). `p` itself otherwise, or when that reading is too old
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
if p.block_start is None or p.elapsed_s < QUARTER_S \
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
return p
held = max(p.held_w, 0.0)
return Projection(projected_w=held, elapsed_w=held,
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
complete=True,
block_start=p.block_start + timedelta(seconds=QUARTER_S),
held_w=p.held_w)
@dataclass(frozen=True)
class _ClosedBlock:
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
+461
View File
@@ -0,0 +1,461 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Solar"},
"inverters.modes": {"name": "Battery modes"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit", "description": "The add-on aims a little below your limit, to be safe."},
"safety.start": {"name": "Starting and stopping"},
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
"now": {"name": "Applies right away"}
},
"home": {
"app": {"name": "Battery add-on"},
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
"plan": {
"name": "Today's plan",
"description": "Point at a bar or a block for details, or tap it on a phone.",
"cheap": "Low price",
"dear": "High price",
"estimate": "Estimate",
"grid": "Charges from the grid",
"give": "Gives power",
"cycle": "Monthly battery cycle",
"peak": "Peak limit",
"house": "Follows the house",
"now": "now",
"time_to": "to",
"per_kwh": "per kWh",
"cheap_tip": "low for today",
"dear_tip": "high for today",
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words",
"average_tip": "hour average"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
"dialog": {
"cancel": "Cancel",
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
},
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."},
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
},
"today": "Today", "why": "Why",
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
"modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
},
"choice_help": {
"meter_source": {
"off": {"description": "A Home Assistant entity that reads grid power in W, positive when you take from the grid."},
"ha_dsmr": {"description": "Two Home Assistant entities from the DSMR integration, one for import and one for export."},
"mqtt_p1": {"description": "The meter's readings arrive over MQTT on a topic you name."},
"ha_signed": {"description": "One Home Assistant entity that reads net grid power, positive for import."},
"homewizard_local": {"description": "The add-on reads a HomeWizard meter on your network, and can spot a stuck meter."}
}
},
"settings": {
"name": "Settings",
"description": "Every setting, grouped by task.",
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
"simple": {"description": "The choices a home owner makes.",
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}, "forecast": {"description": "With a forecast, a battery buys only what the sun will not give."}},
"batteries": {"name": "Batteries"},
"more": {"description": "Looking for the meter, inverters or prices?"},
"installer": "Open the installer view"},
"classic": "Classic settings",
"search": {"name": "Find a setting", "description": "Find a setting, for example peak or meter."},
"no_match": {"description": "No setting matches your search."},
"loading": "Loading the settings",
"load_failed": {"name": "Cannot read the settings", "description": "The add-on could not read its stored settings, so use Classic settings or the Configuration tab."},
"footer": {"description": "Every change is checked by the add-on's own start checks before it is saved."},
"to_fix": "to fix",
"to_check": "to check",
"changed": "changed",
"on": "On",
"off": "Off",
"not_set": "Not set",
"locked": "Locked",
"change": "Change",
"support": {"description": "These were measured on real hardware, so change them only when support asks."},
"picker": {
"choose": "Choose an entity",
"search": {"description": "Search by name or entity id."},
"all": "Show every entity",
"use": "Use this entity id",
"clear": "Clear",
"missing": "Not in Home Assistant",
"none": {"description": "Nothing matches, so check the entity in Developer Tools."}
},
"add_inverter": "Add an inverter",
"inverter": "Inverter",
"remove": "Remove",
"add_row": "Add a row",
"add_solar": "Add a solar inverter",
"periods": {"window": "Window", "from": "From", "to": "To", "days": "Days", "price": "Price", "add": "Add a time window"},
"reversed": "Reads negative",
"phase": "Phase",
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"region_year": "{year} figures",
"hour": "{hour}:00 UTC",
"preset": {"description": "Filled in by the region above, so choose your own figure to change it."},
"cycle": {"name": "Run a cycle by hand", "description": "The same actions as on Home, each asking first."},
"savebar": {"label": "Unsaved changes", "one": "change not saved", "many": "changes not saved", "discard": "Discard", "save": "Save", "saving": "Saving"},
"saved": {"name": "Saved, not running yet", "description": "The add-on runs the settings it started with until you restart it."},
"differs": {"name": "Stored settings are not running", "description": "Some stored settings differ from what runs now, so restart the add-on to apply them."},
"stale": {"name": "Changed somewhere else", "description": "These settings changed elsewhere while you edited, so save again to keep yours or discard to load theirs."},
"other": "Other messages",
"restart": "Restart now",
"dialog": {"name": "Restart the add-on?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Restart now"},
"toast": {
"saved": "Saved. Restart to apply.",
"discarded": "Changes discarded.",
"failed": "That did not work. Try again.",
"blocked": {"description": "Fix the settings marked in red, then save again."},
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
},
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
"derive": {
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"no_inverter": {"description": "Add at least one inverter."},
"flat_named": {"description": "Add a second inverter before naming this one."},
"no_source": {"description": "Choose where the dynamic prices come from."},
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
"no_price": {"description": "Enter a price per kWh."},
"no_periods": {"description": "Add at least one time window."},
"period_time": {"description": "Window {row}: write each time as hours and minutes, like 07:00 or 24:00."},
"period_zero": {"description": "Window {row} starts and ends at the same time, so change one of them."},
"period_price": {"description": "Window {row}: enter a price per kWh."},
"period_price_range": {"description": "Window {row}: enter the price in euro per kWh, like 0.34."},
"period_overlap": {"description": "Windows {a} and {b} both cover {day} {from} to {to}, so change one of them."},
"period_gap": {"description": "No window covers {day} {from} to {to}, so add one or widen one."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
},
"setup": {
"name": "Setup",
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
"step": "Step {n} of {total}",
"back": "Back",
"next": "Next",
"skip": "Next, or skip",
"finish": "Save and restart",
"not_now": "Not now",
"steps": {
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
},
"modes_later": {"description": "After setup, turn on battery modes to choose a mode for each battery on the Batteries screen."},
"kettle": {
"name": "Check the direction",
"description": "Read the meter, switch on a kettle, then read the meter again.",
"read": "Read the meter",
"again": "Read again",
"skip": "Skip this test",
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction before you start control."},
"tested": {"description": "The meter direction was tested with a kettle."},
"needed": {"description": "Do the kettle test first, or skip it."}
},
"check": {
"name": "What the add-on checked",
"running": {"description": "Checking with the add-on's own start checks."},
"pass": {"description": "The add-on's start checks pass."},
"failed": {"description": "The check did not run, so try again."},
"again": "Check again",
"blocked": {"description": "Fix the items marked in red, then check again."}
},
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
"home": "Open Home"
}
},
"home_words": {
"mode_house": "Follows the house",
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "cheap_hours": "Cheap hours only", "off": "Off"},
"attn_shadow": "Shadow mode is on",
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
"zero_held": "Held at 0 W",
"zero_going": "Going to 0 W",
"verdict": {
"held": "Held at 0 W",
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house"
},
"pill": {
"running": "Running",
"shadow": "Shadow mode",
"stopped": "Stopped",
"alarm": "Needs you now",
"waiting": "Waiting for Home Assistant",
"warning": "Running, with a warning"
},
"banner_sub": {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"waiting": "Control resumes by itself when Home Assistant answers.",
"warning": "See Needs attention below."
},
"tile": {
"not_set_up": "Not set up",
"no_reading": "No reading",
"producing": "Producing",
"not_producing": "Not producing",
"needs_solar": "Needs a solar reading",
"not_adding_up": "Readings do not add up",
"using": "Using",
"taking": "Taking from the grid",
"feeding": "Feeding in",
"balanced": "Balanced",
"giving": "Giving power",
"charging": "Charging",
"resting": "Resting"
},
"now": {
"no_reading": "No reading from this battery.",
"would_rest": "{zero}. Would rest now.",
"would_give": "{zero}. Would give {kw} now.",
"would_charge": "{zero}. Would charge {kw} now.",
"held": "{zero} until the alarm clears.",
"stopped": "{zero} while control is stopped.",
"cycle_drain": "Monthly cycle: emptying.",
"cycle_charge": "Monthly cycle: refilling.",
"cycle_hold": "Monthly cycle: holding full.",
"cycle": "Monthly cycle under way.",
"giving": "Giving {kw} now.",
"charging": "Charging {kw} now.",
"resting": "Resting now.",
"off_resting": "Off. Resting now.",
"off_peak": "Off, but gives {kw} for the peak limit.",
"off_going": "Off. Still at {kw}, going to 0 W."
},
"why": {
"no_reading": "No reading, so the add-on cannot tell.",
"held": "An alarm stopped control. See Needs attention.",
"stopped": "Control is stopped.",
"shadow": "Shadow mode: the add-on only shows what it would do.",
"cycle": "The monthly cycle keeps its level reading right.",
"cycle_mode": "The monthly cycle comes first. Its mode resumes after.",
"off": "You switched it off.",
"off_peak": "The peak limit comes before Off. It rests again after.",
"refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.",
"refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.",
"peak": "The peak limit needs power from the batteries.",
"modes_off": "Battery modes are off, so it follows the house.",
"solar_first": "Solar first: it gets spare solar before the others.",
"leftover": "It gets the solar that Solar first batteries cannot take.",
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over.",
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
"grid_charge": "Prices are low, so it charges from the grid.",
"rests_charge": "Another battery charges from the grid, so it rests.",
"selling": "Prices are high, so it sells to the grid.",
"expensive": "Prices are high, so it covers the house.",
"saves": "Saves its power for {at}.",
"saves_later": "Saves its power for higher prices.",
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
"off": "Off, so no monthly cycle today.",
"house": "Follows the house.",
"house_would": "Would follow the house.",
"stopped": "Nothing planned while control is stopped.",
"held": "Nothing planned until the alarm clears."
},
"head": {
"held": "Every inverter is at 0 W until the alarm clears.",
"held_going": "Every inverter goes to 0 W until the alarm clears.",
"stopped": "Control is stopped, so nothing is planned.",
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
"shadow": "Shadow mode: this is what the add-on would do.",
"cycle_would": "A battery would run its monthly cycle today.",
"trade_would": "Would charge when cheapest and give power when dearest.",
"house_would": "Would charge from spare solar and cover the house."
},
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_why": "Lowest published price, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
"give_what_would": "Would give up to {kwh}, to the house first.",
"give_why": "Highest published price, {eur} per kWh.",
"sell_what": "Sells up to {kwh}, to the house first.",
"sell_what_would": "Would sell up to {kwh}, to the house first.",
"cheap_why": "Cheapest hours, about {eur} per kWh.",
"dear_why": "Expensive hours, about {eur} per kWh.",
"give_amount": "Worth up to {eur} at that price.",
"cycle_start": "Starts the monthly battery cycle.",
"cycle_start_would": "Would start the monthly battery cycle.",
"cycle_now": "Runs the monthly battery cycle now.",
"cycle_now_would": "Would run the monthly battery cycle now.",
"cycle_why": "Empties, refills and holds full, so the level reads right.",
"cycle_why_would": "Would empty, refill and hold full, so the level reads right.",
"cycle_amount_unknown": "How long depends on the battery.",
"cycle_amount": "Takes about {hours} hours.",
"peak_what": "Gives power to stay under {kw}.",
"peak_what_would": "Would give power to stay under {kw}.",
"peak_why": "The peak limit comes before everything else.",
"house_what": "Follows the house.",
"house_what_would": "Would follow the house.",
"house_why": "Charges from spare solar, gives power when the house needs it.",
"house_why_would": "Would charge from spare solar, give power when the house needs it.",
"house_amount": "Amounts depend on the sun and the house."
},
"no_trade": {
"not_covered": "No trading on price: nothing measures the 15-minute peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown."
},
"tomorrow_trade": {
"grid": "Tomorrow: charges at {at}, {eur} per kWh.",
"grid_would": "Tomorrow: would charge at {at}, {eur} per kWh.",
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
},
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
"none": "No prices for today."
},
"tomorrow": {
"known": "Tomorrow's prices are in.",
"unknown": "Tomorrow's prices usually arrive after 13:00."
},
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
"own": "Levies and VAT: your own figure."
},
"peak_not_covered": "No peak limit: this meter does not measure 15-minute periods.",
"control": {
"shadow": "Shadow mode: every inverter stays at 0 W.",
"running": "Control is running.",
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"home_assistant": "Home Assistant is not answering",
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": "Meter reading may be stuck",
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": "Power request too high",
"not_keeping_up": "Battery is not keeping up",
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": "Inverters working against each other",
"delivery": "An inverter is not delivering",
"estop": "Emergency stop",
"pv": "Solar reading",
"solar_forecast": "Solar forecast",
"none": "Inverter connection"
},
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
}
}
+382
View File
@@ -0,0 +1,382 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Waar de meting van het net vandaan komt."},
"inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."},
"tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."},
"safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."},
"maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."},
"tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Zonnepanelen"},
"inverters.modes": {"name": "Batterijmodi"},
"tariff.selling": {"name": "Laden en verkopen op prijs"},
"tariff.feed": {"name": "Details van de prijsfeed"},
"safety.peak": {"name": "Piekgrens", "description": "De add-on mikt iets onder uw grens, voor de zekerheid."},
"safety.start": {"name": "Starten en stoppen"},
"safety.stops": {"name": "Veiligheidsstops", "description": "Gemeten op echte hardware, dus laat ze zoals ze zijn."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Omvormers van de zonnepanelen", "description": "De vermogenssensor van elke zonne-omvormer, die de add-on bijhoudt maar niet gebruikt om te regelen."},
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
},
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
"tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
"plan": {
"name": "Plan voor vandaag",
"description": "Wijs een staaf of blok aan voor details, of tik erop op een telefoon.",
"cheap": "Lage prijs",
"dear": "Hoge prijs",
"estimate": "Schatting",
"grid": "Laadt van het net",
"give": "Levert stroom",
"cycle": "Maandelijkse batterijcyclus",
"peak": "Piekgrens",
"house": "Volgt het huis",
"now": "nu",
"time_to": "tot",
"per_kwh": "per kWh",
"cheap_tip": "laag voor vandaag",
"dear_tip": "hoog voor vandaag",
"estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden",
"average_tip": "gemiddelde van het uur"
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
"dialog": {
"cancel": "Annuleren",
"stop": {"name": "Regeling stoppen?", "description": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.", "yes": "Regeling stoppen"},
"start": {"name": "Regeling starten?", "description": "De add-on regelt de batterijen weer en heft een veiligheidsstop op.", "yes": "Regeling starten"},
"maint_start": {"name": "Batterijcyclus starten?", "description": "De batterij loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"},
"maint_abort": {"name": "Batterijcyclus stoppen?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}
},
"toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."},
"offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."},
"setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"},
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
},
"today": "Vandaag", "why": "Waarom",
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
"modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
},
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
"choice_help": {
"meter_source": {
"off": {"description": "Een Home Assistant-entiteit met het netvermogen in W, positief als u van het net afneemt."},
"ha_dsmr": {"description": "Twee Home Assistant-entiteiten van de DSMR-integratie, een voor afname en een voor injectie."},
"mqtt_p1": {"description": "De meetwaarden komen via MQTT binnen op een topic dat u opgeeft."},
"ha_signed": {"description": "Eén Home Assistant-entiteit met het nettovermogen, positief bij afname."},
"homewizard_local": {"description": "De add-on leest een HomeWizard-meter op uw netwerk en merkt een vastgelopen meter op."}
}
},
"settings": {
"name": "Instellingen",
"description": "Elke instelling, per taak.",
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
"simple": {"description": "De keuzes die een eigenaar maakt.",
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}, "forecast": {"description": "Met een prognose koopt een batterij alleen wat de zon niet geeft."}},
"batteries": {"name": "Batterijen"},
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
"installer": "De installateursweergave openen"},
"classic": "Klassieke instellingen",
"search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."},
"no_match": {"description": "Geen instelling past bij uw zoekopdracht."},
"loading": "Instellingen laden",
"load_failed": {"name": "Instellingen niet leesbaar", "description": "De add-on kon zijn bewaarde instellingen niet lezen, dus gebruik Klassieke instellingen of het tabblad Configuratie."},
"footer": {"description": "Elke wijziging gaat door de startcontroles van de add-on voor ze bewaard wordt."},
"to_fix": "op te lossen",
"to_check": "na te kijken",
"changed": "gewijzigd",
"on": "Aan",
"off": "Uit",
"not_set": "Niet ingesteld",
"locked": "Vergrendeld",
"change": "Wijzigen",
"support": {"description": "Deze zijn gemeten op echte hardware, dus wijzig ze alleen als support erom vraagt."},
"picker": {"choose": "Kies een entiteit", "search": {"description": "Zoek op naam of entiteit-id."}, "all": "Alle entiteiten tonen", "use": "Deze entiteit-id gebruiken", "clear": "Wissen", "missing": "Niet in Home Assistant", "none": {"description": "Niets gevonden, dus kijk de entiteit na in Ontwikkelhulpmiddelen."}},
"add_inverter": "Omvormer toevoegen",
"inverter": "Omvormer",
"remove": "Verwijderen",
"add_row": "Regel toevoegen",
"add_solar": "Zonne-omvormer toevoegen",
"periods": {"window": "Tijdvak", "from": "Van", "to": "Tot", "days": "Dagen", "price": "Prijs", "add": "Tijdvak toevoegen"},
"reversed": "Meet negatief",
"phase": "Fase",
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"region_year": "cijfers van {year}",
"hour": "{hour}:00 UTC",
"preset": {"description": "Ingevuld door de regio hierboven, dus kies een eigen bedrag om het te wijzigen."},
"cycle": {"name": "Een cyclus met de hand starten", "description": "Dezelfde acties als op Start, die elk eerst om bevestiging vragen."},
"savebar": {"label": "Niet bewaarde wijzigingen", "one": "wijziging niet bewaard", "many": "wijzigingen niet bewaard", "discard": "Verwerpen", "save": "Bewaren", "saving": "Bezig met bewaren"},
"saved": {"name": "Bewaard, nog niet actief", "description": "De add-on werkt met de instellingen waarmee hij startte tot u hem herstart."},
"differs": {"name": "Bewaarde instellingen zijn niet actief", "description": "Sommige bewaarde instellingen verschillen van wat nu draait, dus herstart de add-on om ze toe te passen."},
"stale": {"name": "Elders gewijzigd", "description": "Deze instellingen zijn elders gewijzigd terwijl u bewerkte, dus bewaar opnieuw om de uwe te houden of verwerp om de andere te laden."},
"other": "Andere meldingen",
"restart": "Nu herstarten",
"dialog": {"name": "Add-on herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Nu herstarten"},
"toast": {"saved": "Bewaard. Herstart om toe te passen.", "discarded": "Wijzigingen verworpen.", "failed": "Dat lukte niet. Probeer opnieuw.", "blocked": {"description": "Los de rood gemarkeerde instellingen op en bewaar opnieuw."}, "restarting": {"description": "Herstart gevraagd, dus deze pagina antwoordt even niet."}},
"tier": {"bad": "Oplossen", "warn": "Nakijken", "info": "Opmerking", "ok": "In orde"},
"derive": {
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"no_inverter": {"description": "Voeg minstens één omvormer toe."},
"flat_named": {"description": "Voeg een tweede omvormer toe voor u deze een naam geeft."},
"no_source": {"description": "Kies waar de dynamische prijzen vandaan komen."},
"custom_profile": {"description": "Deze tijdvakken kunnen alleen op het tabblad Configuratie gewijzigd worden."},
"no_price": {"description": "Vul een prijs per kWh in."},
"no_periods": {"description": "Voeg minstens één tijdvak toe."},
"period_time": {"description": "Tijdvak {row}: schrijf elk tijdstip als uren en minuten, zoals 07:00 of 24:00."},
"period_zero": {"description": "Tijdvak {row} begint en eindigt op hetzelfde uur, dus wijzig een van beide."},
"period_price": {"description": "Tijdvak {row}: vul een prijs per kWh in."},
"period_price_range": {"description": "Tijdvak {row}: vul de prijs in euro per kWh in, zoals 0,34."},
"period_overlap": {"description": "Tijdvakken {a} en {b} vallen allebei op {day} {from} tot {to}, dus wijzig er een."},
"period_gap": {"description": "Geen tijdvak dekt {day} {from} tot {to}, dus voeg er een toe of maak er een breder."},
"unknown": {"description": "De pagina kan deze keuze niet bewaren, dus laad de pagina opnieuw."}
},
"setup": {
"name": "Installatie",
"description": "Voor installateurs: zes stappen naar een werkende installatie, en elke stap kan later gewijzigd worden onder Instellingen.",
"done_before": {"description": "Deze installatie is al ingesteld, dus een wijziging hier vervangt de bewaarde instellingen."},
"step": "Stap {n} van {total}",
"back": "Terug",
"next": "Volgende",
"skip": "Volgende, of overslaan",
"finish": "Bewaren en herstarten",
"not_now": "Niet nu",
"steps": {
"1": {"name": "Uw meter", "description": "De add-on stuurt de netmeting naar nul, dus eerst is uw meter nodig."},
"2": {"name": "Omvormers en batterijen", "description": "Eén kaart per omvormer met een batterij, met zijn entiteiten in Home Assistant."},
"3": {"name": "Zonnepanelen", "description": "Niet verplicht, dus sla deze stap over als de add-on de zonneproductie niet moet bijhouden."},
"4": {"name": "Prijzen", "description": "Wat u per kWh betaalt, zodat de batterijen laden als stroom goedkoop is."},
"5": {"name": "Piekgrens", "description": "Het net blijft onder deze grens, gemiddeld over elk kwartier."},
"6": {"name": "Nakijken en starten", "description": "De add-on kijkt alles na met zijn eigen startcontroles en herstart dan."}
},
"modes_later": {"description": "Zet na de installatie batterijmodi aan om per batterij een modus te kiezen op het scherm Batterijen."},
"kettle": {
"name": "Richting nakijken",
"description": "Lees de meter af, zet een waterkoker aan en lees de meter opnieuw af.",
"read": "Meter aflezen",
"again": "Opnieuw aflezen",
"skip": "Deze test overslaan",
"before": {"description": "De meter geeft nu {w} aan, dus zet een waterkoker aan en druk op Opnieuw aflezen."},
"unread": {"description": "De meter is nu niet leesbaar, dus kijk de entiteit na en probeer opnieuw."},
"none": {"description": "De meting veranderde maar {d}, dus zet de waterkoker aan en lees opnieuw af."},
"ok": {"description": "Goed, de meting steeg met {d} toen de waterkoker aanging."},
"flip": {"description": "De meting daalde met {d}, dus de add-on keert het teken voor u om."},
"wrong_signed": {"description": "De meting daalde met {d}, dus kies hierboven “Eén entiteit voor netvermogen”, die het kan omkeren."},
"wrong_dsmr": {"description": "De meting daalde met {d}, dus kijk na of afname en injectie niet omgewisseld zijn."},
"self": {"description": "De add-on leest deze meter zelf, dus Start toont de richting na de herstart."},
"skipped": {"description": "De richting van de meter is niet getest, dus kijk ze na op Start na de herstart."},
"skipped_wrong": {"description": "Bij de waterkokertest daalde de meting, dus kijk de richting na in stap 1 voor u de regeling start."},
"skipped_down": {"description": "De meting daalde toen de waterkoker aanging, dus kijk de richting van de meter na voor u de regeling start."},
"tested": {"description": "De richting van de meter is getest met een waterkoker."},
"needed": {"description": "Doe eerst de waterkokertest, of sla hem over."}
},
"check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}},
"dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"},
"dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_setup_live": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en dan gelden ze.", "yes": "Bewaren en herstarten"},
"done": {"name": "Installatie is klaar", "description": "De add-on herstart nu in schaduwmodus, dus open Start over een minuut om te zien wat hij zou doen."},
"done_live": {"name": "Installatie is klaar", "description": "De add-on herstart nu, dus open Start over een minuut om de regeling te starten."},
"restart_failed": {"name": "Bewaard, nog niet actief", "description": "De herstart lukte niet, dus druk op Nu herstarten om de instellingen toe te passen."},
"home": "Start openen"
}
},
"home_words": {
"mode_house": "Volgt het huis",
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "cheap_hours": "Alleen goedkope uren", "off": "Uit"},
"attn_shadow": "Schaduwmodus staat aan",
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
"zero_held": "Op 0 W gehouden",
"zero_going": "Gaat naar 0 W",
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
"now": {
"no_reading": "Geen meting van deze batterij.",
"would_rest": "{zero}. Zou nu rusten.",
"would_give": "{zero}. Zou nu {kw} leveren.",
"would_charge": "{zero}. Zou nu {kw} laden.",
"held": "{zero} tot het alarm weg is.",
"stopped": "{zero} zolang de regeling gestopt is.",
"cycle_drain": "Maandelijkse cyclus: leegmaken.",
"cycle_charge": "Maandelijkse cyclus: opladen.",
"cycle_hold": "Maandelijkse cyclus: vol houden.",
"cycle": "Maandelijkse cyclus bezig.",
"giving": "Levert nu {kw}.",
"charging": "Laadt nu {kw}.",
"resting": "Rust nu.",
"off_resting": "Uit. Rust nu.",
"off_peak": "Uit, maar levert {kw} voor de piekgrens.",
"off_going": "Uit. Nog {kw}, gaat naar 0 W."
},
"why": {
"no_reading": "Geen meting, dus de add-on weet het niet.",
"held": "Een alarm stopte de regeling. Zie Vraagt aandacht.",
"stopped": "De regeling is gestopt.",
"shadow": "Schaduwmodus: de add-on toont alleen wat hij zou doen.",
"cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.",
"cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.",
"off": "U hebt haar uitgezet.",
"off_peak": "De piekgrens gaat voor op Uit. Daarna rust ze weer.",
"refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.",
"refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.",
"peak": "De piekgrens vraagt vermogen van de batterijen.",
"modes_off": "Batterijmodi staan uit, dus ze volgt het huis.",
"solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.",
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
"expensive": "De prijs is hoog, dus ze dekt het huis.",
"saves": "Spaart haar stroom voor {at}.",
"saves_later": "Spaart haar stroom voor hogere prijzen.",
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
"house": "Volgt het huis.",
"house_would": "Zou het huis volgen.",
"stopped": "Niets gepland zolang de regeling gestopt is.",
"held": "Niets gepland tot het alarm weg is."
},
"head": {
"held": "Elke omvormer staat op 0 W tot het alarm weg is.",
"held_going": "Elke omvormer gaat naar 0 W tot het alarm weg is.",
"stopped": "De regeling is gestopt, dus er is niets gepland.",
"cycle": "Een batterij doet vandaag zijn maandelijkse cyclus.",
"trade": "Laadt in het goedkoopste uur, levert stroom in het duurste.",
"house": "Laadt met zonne-overschot en dekt het huis.",
"shadow": "Schaduwmodus: dit is wat de add-on zou doen.",
"cycle_would": "Een batterij zou vandaag zijn maandelijkse cyclus doen.",
"trade_would": "Zou laden als het goedkoopst is en leveren als het duurst is.",
"house_would": "Zou laden met zonne-overschot en het huis dekken."
},
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
"dear_why": "Dure uren, ongeveer {eur} per kWh.",
"give_amount": "Tot {eur} waard aan die prijs.",
"cycle_start": "Start de maandelijkse batterijcyclus.",
"cycle_start_would": "Zou de maandelijkse batterijcyclus starten.",
"cycle_now": "Doet nu de maandelijkse batterijcyclus.",
"cycle_now_would": "Zou nu de maandelijkse batterijcyclus doen.",
"cycle_why": "Maakt leeg, laadt op en houdt vol, zodat het laadniveau klopt.",
"cycle_why_would": "Zou leegmaken, opladen en vol houden, zodat het laadniveau klopt.",
"cycle_amount_unknown": "Hoe lang hangt af van de batterij.",
"cycle_amount": "Duurt ongeveer {hours} uur.",
"peak_what": "Levert stroom om onder {kw} te blijven.",
"peak_what_would": "Zou stroom leveren om onder {kw} te blijven.",
"peak_why": "De piekgrens gaat voor alles.",
"house_what": "Volgt het huis.",
"house_what_would": "Zou het huis volgen.",
"house_why": "Laadt met zonne-overschot, levert als het huis vraagt.",
"house_why_would": "Zou laden met zonne-overschot, leveren als het huis vraagt.",
"house_amount": "Hoeveel hangt af van de zon en het huis."
},
"no_trade": {
"not_covered": "Geen handel op prijs: niets meet de kwartierpiek.",
"no_prices": "Nog geen handel op prijs: er zijn geen prijzen binnen.",
"peak": "Nu geen handel op prijs: de piekgrens gaat voor.",
"not_firm": "Nog geen handel op prijs: de prijzen zijn niet gepubliceerd.",
"not_worth": "Vandaag geen handel op prijs. Het prijsverschil is te klein.",
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
},
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"home_assistant": "Home Assistant antwoordt niet",
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
"meter_frozen": "Meter staat mogelijk vast",
"freeze_check": "Controle op vastgelopen meter",
"month_peak": "Piek van deze maand",
"peak_guard": "Piekgrens",
"peak_forecast": "Prognose voor de piekgrens",
"sensor": "Metingen van de batterij",
"control": "Regeling van de omvormer",
"power_too_high": "Vermogensvraag te hoog",
"not_keeping_up": "Batterij kan niet volgen",
"write": "Vermogensvraag niet aanvaard",
"direction": "Vermogen de verkeerde kant op",
"site_direction": "Vermogen de verkeerde kant op",
"circulating": "Omvormers werken elkaar tegen",
"delivery": "Een omvormer levert niet",
"estop": "Noodstop",
"pv": "Meting van de zonnepanelen",
"solar_forecast": "Zonneprognose",
"none": "Verbinding met de omvormer"
},
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
}
}
+351
View File
@@ -0,0 +1,351 @@
"""UX-13: the new page's layout, written once - screen, group, field.
UI code names option keys only here and in uimodel.py, which holds the
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
schema, rules in validate.py. test_uilayout.py fails CI when an option in
config.yaml has no place here.
A field's `key` is an option key, `inverters.<field>` for one inverter in the
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
The key is also its text key: label and help are translations/*.yaml's entry
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
"""
from dataclasses import dataclass
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
NOW = "now" # runtime state (OPT-11's battery switches), none yet
# Controls are names, not string literals, so a key is never followed by a
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
"number", "text", "select", "locked", "inverter_list", "solar_list")
PERIOD_LIST = "period_list" # P5: time windows, each with its own price
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_LIST)
# What an entity picker offers first. `power_w` is a power sensor the add-on
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
SHOW_OPS = ("==", "!=", "in", ">", "<")
@dataclass(frozen=True)
class Field:
key: str
control: str
unit: str = ""
scale: int = 1 # shown = stored / scale (the peak limit in kW)
step: float = 0 # slider or number step, stored units; 0 = 1
kind: str = "" # entity picker filter, one of ENTITY_KINDS
show: tuple = () # ((name, op, value), ...), all must hold
applies: str = RESTART
simple: bool = False # also on Settings, Simple
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
mirror: str = "" # a choice also copied to this field, if offered
fills: str = "" # a preset choice fills this field (uimodel)
note: str = "" # P3: a line under it on Settings, Simple
@property
def text(self) -> str:
"""Where the label and help live: `translations` or `ui_text`."""
return "ui_text" if self.key.startswith("ui.") else "translations"
@dataclass(frozen=True)
class Part:
id: str # "" is the group's main part
fields: tuple
collapsed: bool = False
@dataclass(frozen=True)
class Group:
id: str
parts: tuple
support_only: bool = False # collapsed, with a "for support" warning
def _src(*values):
return (("meter_source", "in", values),)
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
_MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
_LISTED = (("inverter_shape", "!=", "flat"),)
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
ROSTER_FIELDS = (
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
Field("inverters.batt_invert", TOGGLE, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
wizard=2),
)
GROUPS = (
Group("meter", (
Part("", (
Field("meter_source", CHOICE, wizard=1),
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
wizard=1),
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
Field("p1_net_entity", ENTITY, kind="power_w",
show=_src("ha_signed"), wizard=1),
Field("p1_import_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_export_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
Field("meter_phases", SEGMENTED, wizard=1),
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_signed") + _PHASES, wizard=1),
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
)),
Part("timing", (
Field("meter_max_age_s", SLIDER, "s", step=5),
Field("meter_poll_s", SLIDER, "s",
show=_src("homewizard_local")),
), collapsed=True),
)),
Group("inverters", (
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
# pv.py reads each entity's own unit, so a kW sensor is fine here.
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
wizard=3),)),
# OPT-11: the switch that turns battery modes on. Each battery's mode
# and on/off switch are runtime state on the Batteries screen.
Part("modes", (
Field("battery_modes_enabled", TOGGLE),
# OPT-11 P4: the solar forecast; today's also on Settings, Simple.
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
note="forecast"),
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
# OPT-17: several forecast sensors per day, summed.
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
kind="energy"),
Field("solar_trust_pct", SLIDER, "%", step=5),
)),
)),
Group("tariff", (
Part("", (
# A contract without a feed is also the price used when a feed
# is down, so the fallback follows it (uimodel._tariff_encode).
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
Field("price_formula", TEXT,
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
wizard=4),
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, wizard=4,
fills="price_taxes_eur_kwh"),
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
wizard=4),
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
show=_DYNAMIC, wizard=4),
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
# OPT-11: derived from the batteries' selling switches once battery
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
Field("arbitrage_enabled", TOGGLE),
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
Field("arbitrage_soc_floor", SLIDER, "%"),
Field("arbitrage_soc_ceiling", SLIDER, "%"),
)),
Part("feed", (
Field("price_feed_url", TEXT, show=_DYNAMIC),
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
Field("price_now_entity", ENTITY, kind="any"),
Field("price_avg_entity", ENTITY, kind="any"),
), collapsed=True),
)),
Group("safety", (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5, note="month_peak"),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
note="shadow"),
Field("auto_start", TOGGLE),
Field("estop_fitted", TOGGLE),
Field("stale_input_s", SLIDER, "s"),
Field("meter_freeze_s", SLIDER, "s", step=30,
show=_src("homewizard_local")),
)),
Part("stops", (
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("site_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("site_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
), collapsed=True),
)),
Group("maintenance", (
Part("", (
Field("maintenance_enabled", TOGGLE),
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
Field("maintenance_start_hour", SELECT, show=_MAINT),
Field("maintenance_discharge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_charge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
Field("maintenance_hold_min", SLIDER, "min", step=5,
show=_MAINT),
)),
Part("support", (Field("log_level", SELECT),)),
)),
Group("tuning", (
Part("", (
Field("gain", LOCKED, step=0.05),
Field("slew_w", LOCKED, "W", step=50),
Field("deadband_w", LOCKED, "W"),
Field("target_grid_w", LOCKED, "W"),
Field("step_w", LOCKED, "W"),
Field("saturation_w", LOCKED, "W", step=10),
Field("saturation_cycles", LOCKED),
Field("integrator_max_w", LOCKED, "W", step=100),
Field("heartbeat_s", LOCKED, "s"),
Field("cycle_floor_s", LOCKED, "s"),
)),
), support_only=True),
)
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
# --------------------------------------------------------------------------- #
WIZARD_STEPS = 6
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
# read before and after a kettle is switched on, each with the sign it adds to
# the grid reading, and what each direction means as (outcome, fields it
# sets). Sets None: the options cannot fix it, so the step is not passed. A
# source not listed is read by the add-on itself, so the page cannot test it.
KETTLE_SOURCE = "meter_source"
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
KETTLE = {
"off": ((("meter_entity", 1),),
{"up": ("ok", {"meter_invert": False}),
"down": ("flip", {"meter_invert": True})}),
"ha_signed": ((("p1_net_entity", 1),),
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
}
# Step 6 starts the site in shadow mode unless the installer switches it off:
# (field, value when on, value when off).
SHADOW = ("shadow_mode", "on", "auto")
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
"soc_entity": "inverters",
"batt_entity": "inverters",
"batt_invert": "inverters",
"max_w": "inverters",
"bms_soc_floor": "inverters",
"pv_entity": "ui.solar", # one solar row, stored flat
"pv_entities": "ui.solar",
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
"static_profile": "ui.fallback", # built from the price fields
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
def fields():
"""Every field once, in page order, roster fields after `inverters`."""
for group in GROUPS:
for part in group.parts:
for field in part.fields:
yield group, part, field
if field.control == INVERTER_LIST:
for sub in ROSTER_FIELDS:
yield group, part, sub
def field(key: str) -> Field | None:
return next((f for _g, _p, f in fields() if f.key == key), None)
def visible(fld: Field, model: dict) -> bool:
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
for name, op, value in fld.show:
have = model.get(name)
if op == "==" and have != value:
return False
if op == "!=" and have == value:
return False
if op == "in" and have not in value:
return False
try:
if op == ">" and not float(have) > value:
return False
if op == "<" and not float(have) < value:
return False
except (TypeError, ValueError):
return False
return True
def range_of(key: str, schema_ui: list):
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
Never typed here: config.yaml is the one place a range is written. A range
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
path = key.split(".")
nodes = schema_ui or []
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
node = next((n for n in nodes if n.get("name") == path[-1]), None)
if not node or "lengthMin" not in node or "lengthMax" not in node:
return None
return node["lengthMin"], node["lengthMax"]
+488
View File
@@ -0,0 +1,488 @@
"""UX-13: stored options <-> the page model, for the keys the page derives.
`options_to_ui()` turns the stored blob into what the page edits: always a list
of inverter cards, solar rows with a "reads negative" toggle, a contract and
simple prices or time windows (P5) instead of `static_profile` JSON, and a
region preset instead of the levies figure. `ui_to_options()` turns it back, so the validator and the
Supervisor always see the real blob.
The rule that makes the round trip exact: the model carries the stored values
of the derived keys (`keep`). A derivation whose page fields still read what
`keep` decodes to writes `keep` back untouched, and only an edited one is
re-encoded. So a blob the page cannot express (a `static_profile` with odd
windows, a malformed roster) survives a save of some other field unchanged.
See DECISIONS/UX-13-one-layout-table.md.
"""
import copy
import json
import re
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH
from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid,
parse_static_profile)
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
"REGION_PRESETS", "PERIOD_DAYS",
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say. `code` names
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
page field it is shown beside."""
def __init__(self, text, code="", field="", **values):
super().__init__(text)
self.code, self.field = code, field
self.values = values # the sentence's {placeholders} (P5)
self.keys = () # the option keys the failed derivation writes
# The page model's inverter count, which show conditions read, and what adding
# a card sets: a second inverter turns the one flat inverter into a list.
COUNT = "inverter_count"
GROWN = {"inverter_shape": "list"}
# --------------------------------------------------------------------------- #
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
# residential card, Flanders, September 2026, VAT included). Wallonia and
# Brussels have a day and a night band (config.yaml) and join with sourced
# figures; until then those sites choose "own".
# --------------------------------------------------------------------------- #
REGION_PRESETS = {
"flanders": {
"year": 2026,
"taxes_eur_kwh": round(sum(v for _n, v in
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
"taxes_profile": "",
},
}
OWN = "own"
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
"max_w", "bms_soc_floor")
_TOL = 1e-9
def _blank(v) -> bool:
return v is None or (isinstance(v, str) and not v.strip())
def _num(v):
try:
return None if _blank(v) or isinstance(v, bool) else float(v)
except (TypeError, ValueError):
return None
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
def _roster_decode(o):
entries = o.get("inverters")
if entries is None or entries == "" or entries == []:
card = {k: o.get(k) for k in _ROSTER_FLAT}
card.update(name="", capacity_kwh=None)
return {"inverter_shape": "flat", "inverters": [card]}
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
return {"inverter_shape": "list",
"inverters": [dict(e) for e in entries]}
# Not something a card can show: edited in the Configuration tab only.
return {"inverter_shape": "raw", "inverters": None}
def _roster_encode(m, keep):
out = {}
shape, cards = m.get("inverter_shape"), m.get("inverters")
if shape == "raw":
raise DeriveError("the inverter list is edited in the Configuration tab",
"raw_roster", "inverters")
if not cards:
raise DeriveError("at least one inverter is needed", "no_inverter",
"inverters")
if shape == "flat" and len(cards) == 1:
card = cards[0]
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
raise DeriveError("one inverter stored flat has no name or "
"capacity", "flat_named", "inverters")
for k in _ROSTER_FLAT:
if card.get(k) is not None or k in keep:
out[k] = card.get(k)
out["inverters"] = []
return out
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
for c in cards]
# The flat keys beside a list are not read: they stay as stored.
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused,
# so a list that replaces the flat inverter clears it.
out["setpoint_entity"] = ""
return out
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
def _strs(v):
return [str(x).strip() for x in (v if isinstance(v, list) else [])
if not _blank(x)]
def _solar_rows(o):
listed = _strs(o.get("pv_entities"))
if listed:
return listed, True
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
return ([one] if one else []), False
def _solar_decode(o):
rows, _listed = _solar_rows(o)
flipped = set(_strs(o.get("pv_invert_entities")))
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
for e in rows]}
def _solar_encode(m, keep):
out = {}
rows = [r for r in (m.get("ui.solar") or [])
if not _blank(r.get("entity"))]
entities = [str(r["entity"]).strip() for r in rows]
old_rows, was_list = _solar_rows(keep)
if len(entities) <= 1 and not was_list:
out["pv_entity"] = entities[0] if entities else ""
if "pv_entities" in keep:
out["pv_entities"] = []
else:
out["pv_entities"] = entities
if "pv_entity" in keep:
out["pv_entity"] = "" # TEL-12: the list replaces it
# Flips of entities that were never a row stay as stored (pv.stray_inverts
# reports them). Flips of removed rows go with the row.
extras = [x for x in _strs(keep.get("pv_invert_entities"))
if x not in old_rows and x not in entities]
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
if flips or "pv_invert_entities" in keep:
out["pv_invert_entities"] = flips
return out
# --- 3. contract and static_profile ----------------------------------------- #
def _fixed_json(price) -> str:
return json.dumps({"periods": [
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
"eur_per_kwh": price}]})
def _daynight_json(day, night) -> str:
return json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": day},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": night},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": night}]})
def _shape(text):
"""The periods of a profile without their names, for comparing, or None."""
try:
prof = parse_static_profile(text)
except ProfileInvalid:
return None
if prof is None:
return ()
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
p.eur_per_kwh) for p in prof.periods))
def _profile_decode(raw):
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
if isinstance(raw, str) and _blank(raw):
return "none", None, None, None
if not isinstance(raw, str):
return "custom", None, None, None
got = _shape(raw)
if not got:
return ("none" if got == () else "custom"), None, None, None
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
return "fixed", got[0][3], None, None
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
if (day is not None and night is not None
and got == _shape(_daynight_json(day, night))):
return "daynight", None, day, night
return "custom", None, None, None
# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- #
# Each row is {"from", "to", "days", "price"} and, when read from a stored
# profile, its "name". `days` is one of these; a profile with any other day set
# is edited in the Configuration tab only (`ui.periods` is None).
PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES
_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else
frozenset(range(5)) if d == "mon-fri" else
frozenset((5, 6)) if d == "sat,sun" else
frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS}
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
def _hhmm(minute) -> str:
return f"{minute // 60:02d}:{minute % 60:02d}"
def _periods_decode(raw):
"""The rows for a stored profile: [] when unset, None when it cannot be
shown as rows (unparsable, or days the editor has no choice for)."""
try:
prof = parse_static_profile(raw) if isinstance(raw, str) else None
except ProfileInvalid:
return None
if prof is None:
return [] if isinstance(raw, str) else None
rows = []
for p in prof.periods:
days = next((d for d, s in _DAYSETS.items() if s == p.days), None)
if days is None:
return None
rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min),
"days": days, "price": p.eur_per_kwh, "name": p.name})
return rows
def _minute(text, *, end):
""""07:00" -> 420, "24:00" -> 1440 as an end, else None."""
m = _TIME.match(str(text or ""))
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if mi > 59 or h > 24 or (h == 24 and (mi or not end)):
return None
return h * 60 + mi
def _periods_json(rows) -> str:
"""The static_profile for the editor's rows, or DeriveError naming the
first problem: a bad time, an empty price, an overlap or a gap."""
if not rows:
raise DeriveError("add at least one time window", "no_periods",
"ui.periods")
periods = []
for i, row in enumerate(rows, start=1):
row = row if isinstance(row, dict) else {}
start = _minute(row.get("from"), end=False)
end = _minute(row.get("to"), end=True)
if start is None or end is None:
raise DeriveError(f"window {i} has a bad time", "period_time",
"ui.periods", row=i)
end = end or MINUTES_PER_DAY # "00:00" as an end
if start == end:
raise DeriveError(f"window {i} is empty", "period_zero",
"ui.periods", row=i)
price = _num(row.get("price"))
if price is None:
raise DeriveError(f"window {i} needs a price", "period_price",
"ui.periods", row=i)
if abs(price) > MAX_PLAUSIBLE_EUR_KWH:
raise DeriveError(f"window {i}'s price is out of range",
"period_price_range", "ui.periods", row=i)
if row.get("days") not in _DAYSETS:
raise DeriveError(f"window {i} has no days", "unknown",
"ui.periods")
name = str(row.get("name") or "").strip() or f"window{i}"
periods.append(Period(name=name, start_min=start, end_min=end,
days=_DAYSETS[row["days"]],
eur_per_kwh=price))
_tiling(periods)
return json.dumps({"periods": [
{"name": p.name, "from": _hhmm(p.start_min),
"to": _hhmm(p.end_min), "days": row["days"],
"eur_per_kwh": p.eur_per_kwh}
for p, row in zip(periods, rows)]})
def _tiling(periods):
"""Each minute of the week in exactly one window, as static_tariff.py
requires. The first overlap or gap, by day and time, is the error."""
for day in range(7):
start, kind, rows = None, None, None
for minute in range(MINUTES_PER_DAY + 1):
here = [i for i, p in enumerate(periods, start=1)
if minute < MINUTES_PER_DAY and p.covers(day, minute)]
now = ("gap" if not here else "overlap" if len(here) > 1
else None) if minute < MINUTES_PER_DAY else "end"
if kind is not None and (now != kind or (kind == "overlap"
and here != rows)):
values = {"day": DAY_NAMES[day], "from": _hhmm(start),
"to": _hhmm(minute)}
if kind == "gap":
raise DeriveError("a time has no window", "period_gap",
"ui.periods", **values)
raise DeriveError("two windows cover one time",
"period_overlap", "ui.periods",
a=rows[0], b=rows[1], **values)
if now in ("gap", "overlap") and kind is None:
start, kind, rows = minute, now, here
def _tariff_decode(o):
raw = o.get("static_profile", "")
kind, fixed, day, night = _profile_decode(raw)
provider = o.get("price_provider")
dynamic = not _blank(provider) and provider != "off"
return {"ui.contract": "dynamic" if dynamic else kind,
"price_provider": provider, "ui.fallback": kind,
"ui.fixed_price": fixed, "ui.day_price": day,
"ui.night_price": night, "ui.periods": _periods_decode(raw)}
def _tariff_encode(m, keep):
out = {}
contract = m.get("ui.contract")
if contract == "dynamic":
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
raise DeriveError("a dynamic contract needs a price source",
"no_source", "price_provider")
out["price_provider"] = m["price_provider"]
kind = m.get("ui.fallback")
else:
out["price_provider"] = "off"
kind = contract
if kind == "none":
out["static_profile"] = ""
elif kind == "fixed":
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
elif kind == "daynight":
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
_price_of(m, "ui.night_price"))
elif kind == "custom":
rows = m.get("ui.periods")
if rows is not None:
out["static_profile"] = _periods_json(rows)
elif "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab", "custom_profile",
"ui.contract")
else:
# Days the editor cannot show: kept as stored.
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}", "unknown",
"ui.contract")
return out
def _price_of(m, name):
value = _num(m.get(name))
if value is None:
raise DeriveError(f"{name} needs a price", "no_price", name)
return value
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
def _region_decode(o):
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
region = OWN
for rid, p in REGION_PRESETS.items():
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
if same and str(profile or "").strip() == p["taxes_profile"]:
region = rid
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
def _region_encode(m, keep):
region = m.get("ui.region")
if region == OWN:
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
if "price_taxes_profile" in keep: # "own" keeps the stored table
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region")
p = REGION_PRESETS[region]
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
if p["taxes_profile"] or "price_taxes_profile" in keep:
out["price_taxes_profile"] = p["taxes_profile"]
return out
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
def arbitrage_from_batteries(m):
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
battery may sell. Battery modes are not built yet, so this returns None and
the toggle is stored as the page has it: no behaviour change."""
return None
def _arbitrage_decode(o):
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
def _arbitrage_encode(m, keep):
derived = arbitrage_from_batteries(m)
value = m.get("arbitrage_enabled") if derived is None else derived
return {} if value is None and "arbitrage_enabled" not in keep \
else {"arbitrage_enabled": value}
# (option keys it writes, decode, encode). Each is independent of the others.
DERIVATIONS = (
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
_solar_encode),
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
_region_encode),
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
)
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
def options_to_ui(options: dict) -> dict:
"""The page model for a stored blob. Pure: reads nothing else."""
model = {k: copy.deepcopy(v) for k, v in options.items()
if k not in _OWNED}
for _keys, decode, _encode in DERIVATIONS:
model.update(decode(options))
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
if k in options}
cards = model.get("inverters")
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
preset = REGION_PRESETS.get(model["ui.region"])
model["region_year"] = preset["year"] if preset else None
return model
def ui_to_options(model: dict) -> dict:
"""The options blob for a page model. Raises DeriveError on a model no blob
can express, so the page says so instead of saving something else."""
out, errors = ui_to_options_partial(model)
if errors:
raise errors[0]
return out
def ui_to_options_partial(model: dict):
"""(blob, [DeriveError]): every derivation that can be made, and the
stored values (`keep`) for each one that cannot. For the checks only, so a
step can be blocked by its own field and the rest still checked (UX-15
F1). Never saved: `ui_to_options()` raises instead."""
keep = model.get("keep") or {}
out = {k: copy.deepcopy(v) for k, v in model.items()
if k not in _MODEL_ONLY and k not in _OWNED
and not k.startswith("ui.")}
errors = []
for keys, decode, encode in DERIVATIONS:
was = decode(keep)
if all(model.get(name) == value for name, value in was.items()):
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
continue
try:
out.update(encode(model, keep))
except DeriveError as err:
err.keys = keys
errors.append(err)
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
return out, errors
+403
View File
@@ -0,0 +1,403 @@
"""UX-13 P2: the settings view's server half. It names no option key.
It turns app/uilayout.py's table into the JSON the page draws from (labels
from the Supervisor's translations, ranges and choices from its schema, page
words from app/ui_text), turns a posted page model into options through
app/uimodel.py, and says beside which page field each validator sentence
goes. The rules stay in validate.py and the sentences are passed on as they
are (DECISIONS/UX-13-one-layout-table.md).
"""
import copy
import os
import re
from . import uilayout as L
from . import uitext
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
# A choice the page cannot make, only keep. None since P5's period editor.
ONLY_IF_HELD = ()
_TIERS = ("bad", "warn", "info")
def ui_text(lang="en") -> dict:
"""The page words in `lang`, English where Dutch has no key (P5)."""
return copy.deepcopy(uitext.words(lang))
def _roster():
"""The inverter list field's key, read off the table."""
return next(f.key for _g, _p, f in L.fields()
if f.control == L.INVERTER_LIST)
def _node(schema_ui, key):
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
nodes, path = schema_ui or [], key.split(".")
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
def _words(translations, lang, key, T):
"""(label, help) for a field: ui_text for `ui.` fields, else the
translation without its `Group · ` prefix, and the raw key last."""
if key.startswith("ui."):
entry = T["fields"].get(key[3:], {})
return entry.get("name", key), entry.get("description", "")
path = tuple(key.split(".")[:-1])
name = key.split(".")[-1]
label, _lang = label_of(translations, lang, name, path)
text, _lang = help_of(translations, lang, name, path)
return split_group(label)[1], text
def _choices(fld, node, T, value_type):
"""[[value, label, only_if_held], ...] for a choice control, or None."""
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
return None
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
words = T["choices"].get(name) or {}
if fld.key.startswith("ui."):
values = list(words)
elif node.get("options"):
values = [v for v in node["options"] if not words or v in words]
elif value_type == "integer" and node.get("lengthMax") is not None:
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
if words:
values = [v for v in values if str(v) in words]
else:
return []
out = []
for v in values:
label = words.get(str(v))
if label is None:
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
if isinstance(v, int) else str(v))
preset = U.REGION_PRESETS.get(v) if fld.fills else None
if preset:
label = f"{label}, " + T["settings"]["region_year"].replace(
"{year}", str(preset["year"]))
out.append([v, label, v in ONLY_IF_HELD])
return out
def _field(fld, schema_ui, translations, lang, T):
node = _node(schema_ui, fld.key)
label, text = _words(translations, lang, fld.key, T)
if fld.unit:
# The page shows the unit beside the control, in its own scale (kW),
# so the translation's "(W)" goes; "(optional)" stays.
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
else node.get("type") or "")
out = {"key": fld.key, "control": fld.control, "label": label,
"help": text, "unit": fld.unit, "scale": fld.scale,
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
if how in (fld.key, fld.mirror))}
if node.get("lengthMin") is not None:
out["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
out["max"] = node["lengthMax"]
choices = _choices(fld, node, T, value_type)
if choices is not None:
out["choices"] = choices
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
later = T.get("choices_later", {}).get(name)
if later:
out["later"] = [[v, words] for v, words in later.items()]
hints = T.get("choice_help", {}).get(name)
if hints:
out["hints"] = {v: e.get("description", "")
for v, e in hints.items()}
if fld.control == L.PERIOD_LIST:
# P5: the day choices of one time window, in the editor's order.
names = T["choices"]["period_days"]
out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS]
if fld.mirror:
out["mirror"] = fld.mirror
if fld.fills:
out["fills"] = fld.fills
out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]}
for rid, p in U.REGION_PRESETS.items()}
roster = _roster()
if fld.key.startswith(roster + "."):
sub = fld.key[len(roster) + 1:]
if L.DERIVED.get(sub) == roster:
out["flat"] = sub # how the one flat inverter stores it
if fld.control == L.INVERTER_LIST:
out["fields"] = [_field(sub, schema_ui, translations, lang, T)
for sub in L.ROSTER_FIELDS]
out["count"] = U.COUNT
out["grow"] = dict(U.GROWN)
out["shape"] = next(iter(U.GROWN))
return out
def layout(schema_ui, translations, lang="en") -> dict:
"""Every group, part and field of the Installer view, as the page draws
it. Built from the table only: a field the table places is on the page."""
T = ui_text(lang)
groups = []
for g in L.GROUPS:
parts = []
for p in g.parts:
words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {}
parts.append({"id": p.id, "name": words.get("name", ""),
"description": words.get("description", ""),
"collapsed": p.collapsed,
"fields": [_field(f, schema_ui, translations, lang, T)
for f in p.fields]})
words = T["groups"].get(g.id, {})
applies = sorted({f.applies for p in g.parts for f in p.fields})
groups.append({"id": g.id, "name": words.get("name", g.id),
"description": words.get("description", ""),
"support_only": g.support_only,
"applies": [T["applies"][a]["name"] for a in applies],
"parts": parts})
return {"groups": groups}
# --------------------------------------------------------------------------- #
# The page model -> options, for the validator and the save
# --------------------------------------------------------------------------- #
def _same(a, b) -> bool:
"""Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not."""
if isinstance(a, bool) or isinstance(b, bool):
return type(a) is type(b) and a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def to_options(model: dict, stored: dict | None = None) -> dict:
"""The options blob for a posted page model. A value equal to the stored
one is written as stored, in the stored key order, so a save that changed
nothing writes an identical blob (the browser reads 3.0 as 3)."""
return _settle(U.ui_to_options(model), stored)
def to_options_partial(model: dict, stored: dict | None = None):
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
settled like `to_options`. Never saved (UX-15 F1)."""
out, errors = U.ui_to_options_partial(model)
return _settle(out, stored), errors
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
"""The blob to save: `stored`, with the keys whose value in `mine` (this
page's model) differs from `base` (the model it opened) set to `mine`'s.
A key this page never edited keeps what is stored now, even when another
surface changed it meanwhile (UX-13 P4 test, F2)."""
edits = {k: v for k, v in mine.items()
if k not in base or not _same(v, base[k])}
gone = {k for k in base if k not in mine}
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
if k not in gone}
out.update({k: copy.deepcopy(v) for k, v in edits.items()
if k not in out})
return out
def _settle(out: dict, stored: dict | None) -> dict:
stored = stored or {}
for k, v in out.items():
if k in stored and _same(v, stored[k]):
out[k] = copy.deepcopy(stored[k])
return ({k: out[k] for k in stored if k in out}
| {k: v for k, v in out.items() if k not in stored})
def derive_message(err: U.DeriveError, lang="en") -> dict:
T = uitext.words(lang)
words = T["settings"]["derive"]
text = words.get(err.code, words["unknown"])["description"]
args = dict(getattr(err, "values", None) or {})
# A time window's day is shown by name (P5 period editor).
if "day" in args:
args["day"] = T["choices"]["period_days"].get(args["day"], args["day"])
return {"field": err.field or None, "card": None, "tier": "bad",
"text": uitext.say(text, **args)}
def derive_messages(errors, problems, notes, model, keys, lang="en"):
"""The page's messages when part of the model could not become options:
each conversion's own sentence beside its field, then the checks of the
rest. A refusal or note about a key the failed conversion writes is left
out, because it judges the stored value, not what the page shows."""
failed = {k for err in errors for k in err.keys}
by_key, unanchored = problems_by_field(problems, keys)
kept = [t for k, texts in by_key.items() if k not in failed
for t in texts] + list(unanchored)
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
# --------------------------------------------------------------------------- #
# Where a sentence goes on the page
# --------------------------------------------------------------------------- #
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
_SUB = re.compile(r"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
def _mirror_of(key):
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
def targets(key: str, text: str, model: dict) -> list:
"""[(page field, card)] for a sentence that names option `key`. `card` is
the inverter card's index, or None."""
roster = _roster()
cards = model.get(roster) if isinstance(model.get(roster), list) else []
names = [c.get("name") for c in cards if isinstance(c, dict)]
# messages.setting(): “Inverter rating” for inverter-b (inverters.max_w),
# or a roster rule's own `inverters[1] (inverter-b)`.
card = next((i for i, n in enumerate(names)
if n and f"for {n} ({roster}." in text), None)
index = re.search(re.escape(roster) + r"\[(\d+)\]", text)
if card is None and index and int(index.group(1)) < len(cards):
card = int(index.group(1))
if key == roster:
subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card)
for r, i, s in _SUB.findall(text) if r == roster]
subs = [x for x in dict.fromkeys(subs) if L.field(x[0])]
return subs or [(roster, card)]
if key in L.DERIVED:
how = L.DERIVED[key]
if how == roster:
return [(f"{roster}.{key}", 0 if card is None else card)]
fld = L.field(how)
if fld is not None and not L.visible(fld, model) and _mirror_of(how):
return [(_mirror_of(how), None)]
return [(how, None)]
if L.field(key) is not None:
return [(key, None)]
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
out, seen = [], set()
def add(field, card, tier, text):
if (field, card, text) not in seen:
seen.add((field, card, text))
out.append({"field": field, "card": card, "tier": tier,
"text": text})
by_key, unanchored = problems_by_field(problems, keys)
for key, texts in by_key.items():
for text in texts:
for field, card in targets(key, text, model):
add(field, card, "bad", text)
for text in unanchored:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
if (m["field"] or "").startswith(roster + ".")}
return [m for m in out if not (m["field"] == roster
and m["text"] in placed)]
def fields_of(keys, model) -> list:
"""The page fields that show these option keys (a stale save's list)."""
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
if f))
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
# --------------------------------------------------------------------------- #
# Store keys (app/store.py), not options. SETUP_DONE is written when the
# wizard saves. STARTED is the one control's Start and Stop write.
SETUP_DONE = "setup_done"
STARTED = "auto"
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
"""Why the page opens the wizard by itself, or None.
"no_power_request": the stored options name no inverter to control, so
the add-on cannot start on them. "first_start": a new install. This boot
found no Store file (`fresh`), the wizard never saved and control was never
started or stopped. `fresh` is what tells a new install from an old site
that was never started, e.g. one left in shadow for weeks (TEST-23).
`store_data` is the Store's dict (None: unknown, so no), `started` is
whether control is started now."""
if not power_request_entities(stored or {}):
return "no_power_request"
if store_data is None or not fresh:
return None
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
return None
return "first_start"
def setup_spec() -> dict:
"""What the wizard needs beyond each field's step: read off the table."""
kettle = {}
for source, (read, outcomes) in L.KETTLE.items():
kettle[source] = {"read": [[f, sign] for f, sign in read]}
for way, (say, sets) in outcomes.items():
kettle[source][way] = {"say": say, "sets": sets}
field, on, off = L.SHADOW
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
"optional": list(L.OPTIONAL_STEPS),
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
"sources": kettle},
"shadow": {"field": field, "on": on, "off": off}}
def kettle(source, before_w, after_w):
"""The kettle test's verdict, as the page reaches it (settings.js
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
for a source the page cannot read."""
spec = L.KETTLE.get(source)
if spec is None:
return None
change = float(after_w) - float(before_w)
if abs(change) < L.KETTLE_MIN_W:
return "none", None, change
say, sets = spec[1]["up" if change > 0 else "down"]
return say, (dict(sets) if sets is not None else None), change
def step_of(field_key, model) -> int:
"""The wizard step a sentence on this page field blocks: the field's own
step while it is shown, else the last step, which lists everything."""
fld = L.field(field_key) if field_key else None
if fld is None or not fld.wizard or not L.visible(fld, model):
return L.FINAL_STEP
return fld.wizard
def step_problems(messages, model) -> dict:
"""{"1": n, ...}: how many refusals block each wizard step."""
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
for m in messages:
if m.get("tier") == "bad":
out[str(step_of(m.get("field"), model))] += 1
return out
+51
View File
@@ -0,0 +1,51 @@
"""UX-13 P5: the page's words, app/ui_text/<lang>.json, in English or Dutch.
One file per language with the same keys. A key missing from a language falls
back to English, key by key, the way the Supervisor's translations do.
test_ui_text.py holds both files to STYLE.md and to each other.
"""
import copy
import io
import json
import os
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
LANGS = ("en", "nl")
_CACHE: dict = {}
def lang_of(value) -> str:
""""nl", "nl-BE" or "nl_NL" -> "nl"; anything else -> "en"."""
head = str(value or "").strip().lower().replace("_", "-").split("-")[0]
return head if head in LANGS else "en"
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback."""
if lang not in _CACHE:
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
_CACHE[lang] = json.load(f)
return _CACHE[lang]
def _merge(base, over):
if not (isinstance(base, dict) and isinstance(over, dict)):
return copy.deepcopy(over)
out = copy.deepcopy(base)
for k, v in over.items():
out[k] = _merge(base.get(k), v) if k in base else copy.deepcopy(v)
return out
def words(lang="en") -> dict:
"""Every word in `lang`, English where a key is missing."""
lang = lang_of(lang)
return raw("en") if lang == "en" else _merge(raw("en"), raw(lang))
def say(template: str, **values) -> str:
"""Fill `{name}` placeholders, as settings.js `say()` does."""
for k, v in values.items():
template = template.replace("{" + k + "}", str(v))
return template
+508 -18
View File
@@ -243,6 +243,13 @@ NOT READY banner already names the problem, (b) does not apply.
note below on `gain`, which is the term that owns that and which SAFETY-39
measured and did not change.
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
make the day plan buy everything or nothing, which no one means.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -321,8 +328,10 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
"""
import logging
import re
from typing import NamedTuple
from . import messages
from . import dayplan, messages, pv, solarfc
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -330,8 +339,9 @@ from .custom_formula import (
parse_export_formula, parse_price_formula, stale_card)
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .p1 import (
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
from .peakguard import PEAK_MARGIN_RANGE_W, PEAK_OPTION_DEFAULTS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
@@ -343,6 +353,47 @@ from .watchdog import SIGN_OPTION_DEFAULTS
_LOG = logging.getLogger("goodwe.config")
class _WarnOnce(logging.Filter):
"""UX-10: a settings warning is said once per start. The validator runs
twice at start (before the first cycle and again once the inverter's range
is known), and a warning about options that have not changed is the same
news both times. Refusals are ERROR and pass untouched."""
def __init__(self):
super().__init__()
self.seen = set()
def filter(self, record):
if record.levelno != logging.WARNING:
return True
text = record.getMessage()
if text in self.seen:
return False
self.seen.add(text)
return True
WARN_ONCE = _WarnOnce()
_LOG.addFilter(WARN_ONCE)
class Note(NamedTuple):
"""UX-13: a warning or info for the settings page. Never a refusal: the
add-on starts. `keys` are the options it is about, so the page can show it
beside them."""
tier: str # "warn" or "info"; the wizard's checks
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
def _advise(notes, tier: str, keys, text: str) -> None:
"""Add a Note when the caller collects them (the settings page)."""
if notes is not None:
notes.append(Note(tier, tuple(keys), text))
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
# threshold is a compile-time YAML substitution baked into the ESP32 image
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
@@ -765,7 +816,16 @@ class ConfigRefused(Exception):
super().__init__(problems[0] if problems else "configuration refused")
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
def no_power_request_problem() -> str:
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
starts when this is the only refusal (app/setuponly.py)."""
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
"the inverter. Set it to the ESPHome number entity for the "
"setpoint.")
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
notes: list | None = None) -> list[str]:
"""Return the list of reasons this configuration must not be started.
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
@@ -775,15 +835,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
Pure and side-effect free apart from two warnings, so it can be called from
a test or from sim/inject.py without a Home Assistant anywhere near it.
UX-13: pass a list as `notes` to also get each logged warning as a `Note`
for the settings page. The refusals returned do not depend on it.
"""
problems: list[str] = []
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
writers = actuator_writers(opts)
if not writers:
problems.append(
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
"inverter. Set it to the ESPHome number entity for the setpoint.")
problems.append(no_power_request_problem())
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
# count of option keys. Read `actuator_writers()` for why the old check went
# green on the very config this one is here to refuse.
@@ -1103,7 +1164,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"site. Set it to 0 or to at most {bound:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
problems.extend(_price_problems(opts, notes))
# --- 8. an EMS energy floor the BMS does not already enforce -------------
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
@@ -1141,6 +1202,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
"level.")
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
problems += _margin_problems(opts)
problems += _forecast_problems(opts)
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
@@ -1149,6 +1214,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
"but it only adds overshoot, so 0 is recommended.",
integ_w, max_w)
_advise(notes, "warn", ("integrator_max_w",),
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
"adds overshoot, so set it to 0.")
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
@@ -1173,6 +1241,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
_advise(notes, "warn", ("maintenance_discharge_w",),
f"{S('maintenance_discharge_w')} is above "
f"{S('max_w', maint_where)}, so the battery empties at "
f"{messages.watts(maint_cap)} and the cycle takes longer.")
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
@@ -1181,7 +1253,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
return problems
def _price_problems(opts: dict) -> list[str]:
def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
@@ -1314,12 +1386,11 @@ def _price_problems(opts: dict) -> list[str]:
if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1:
_LOG.info(
"static_profile carries one distinct price (%.4f EUR/kWh "
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
"off by arithmetic - a flat series has no spread - and the "
"add-on runs self-consumption only, which is §9.2's handling "
"for this product. Peak shaving, reserve management and the "
"maintenance cycle are unaffected.", profile.prices[0])
"static_profile has one price (%.4f EUR/kWh all-in), so this "
"site has a fully fixed price. A flat price has no spread to "
"trade on, so the add-on uses the battery for self-consumption "
"only. Peak limiting, the reserve and battery maintenance are "
"not affected.", profile.prices[0])
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
@@ -1400,6 +1471,9 @@ def _price_problems(opts: dict) -> list[str]:
"%s has negative levies %s EUR/kWh, which are subtracted from "
"the price. This is allowed, but it is usually a sign error.",
TAXES_PROFILE_OPTION, negative)
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
"lower the price, and that is usually a sign error.")
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1455,6 +1529,10 @@ def _price_problems(opts: dict) -> list[str]:
"price_formula is filled in, but price_provider is %r, so the "
"formula is not used. Set price_provider to 'custom_formula' "
"or clear price_formula.", pid)
_advise(notes, "warn", ("price_formula", "price_provider"),
f"{S('price_formula')} is not used, because "
f"{S('price_provider')} is not 'custom_formula', so clear "
"it or choose that source.")
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1468,6 +1546,8 @@ def _price_problems(opts: dict) -> list[str]:
# exactly right and a site refused over one is a site that
# cannot start.
_LOG.warning("%s", aged)
_advise(notes, "warn", ("price_formula",),
_card_note("price_formula", spec))
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
@@ -1505,9 +1585,9 @@ def _price_problems(opts: dict) -> list[str]:
_LOG.info(
"%s is set: injection priced as (%g x index %+g) x "
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
"IT: arbitrage.py prices the sell side as displaced IMPORT "
"and compares two all-in CONSUMPTION prices, so this option "
"%+.5f EUR/kWh offset. No part of the add-on reads it yet: "
"trading prices the sell side as displaced import and "
"compares two all-in consumption prices, so this setting "
"changes no decision the add-on takes today. It is what you "
"are shown, and the shape a future export-aware strategy "
"would read.",
@@ -1548,6 +1628,9 @@ def _price_problems(opts: dict) -> list[str]:
"%.4g, offset %+.5f EUR/kWh).",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
"only the shape of its numbers is checked.")
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
@@ -1559,6 +1642,8 @@ def _price_problems(opts: dict) -> list[str]:
xaged = stale_card(xspec)
if xaged:
_LOG.warning("%s", xaged)
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
_card_note(EXPORT_FORMULA_OPTION, xspec))
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
@@ -1570,6 +1655,9 @@ def _price_problems(opts: dict) -> list[str]:
"price_provider is %r but static_profile is empty, so a price "
"feed outage leaves no tariff at all. Fill in static_profile as a "
"fallback.", pid)
_advise(notes, "warn", ("static_profile",),
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1621,6 +1709,408 @@ def _price_problems(opts: dict) -> list[str]:
return problems
def _card_note(option: str, spec) -> str:
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
"the new figures if your contract changed.")
# UX-13: entity options whose number the add-on takes as watts, with no unit
# conversion. pv.py converts each solar entity's own unit, so those are not here.
WATT_ENTITY_KEYS = (
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
"p1_phase_net_entities", "p1_phase_import_entities",
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
# The meter entities each `meter_source` reads through Home Assistant.
METER_ENTITY_KEYS = {
"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities", "p1_phase_export_entities")}
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
"""(key, inverter name or "", entity id) for each entity the add-on reads
with these options. Keys the chosen meter source or roster shape ignores
are left out, so a stale default is not reported."""
out = []
def add(key, value, where=""):
for e in (value if isinstance(value, list) else [value]):
if isinstance(e, str) and e.strip():
out.append((key, where, e.strip()))
source = str(opts.get("meter_source") or "off").strip()
for key in METER_ENTITY_KEYS.get(source, ()):
add(key, opts.get(key))
entries = roster_entries(opts)
if not entries:
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
add(key, opts.get(key))
for i, entry in enumerate(entries):
if isinstance(entry, dict):
where = _unit_label(opts, i).strip().strip("()")
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
add(key, entry.get(key), where)
solar = [e for e in (opts.get("pv_entities") or []) if str(e or "").strip()]
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
add(key, opts.get(key))
for key in FORECAST_KEYS:
add(key, opts.get(key))
return out
def config_notes(opts: dict, *, entities: dict | None = None,
month_peak_w=None) -> list[Note]:
"""UX-13: warnings that need live data, for the settings page. Never a
refusal, and nothing here changes what `validate_startup_config()` says.
`entities` is Home Assistant's entity list by id (app/haentities.py), or
None when it could not be read, in which case those checks are skipped.
`month_peak_w` is this month's peak so far, or None.
"""
notes: list[Note] = []
if entities is not None:
_entity_notes(notes, opts, entities)
cap = _number(opts, "peak_cap_w", None)
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
f"This month's peak is already {messages.watts(month_peak_w)}, "
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
return notes
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
"""A note for each entity these options read that Home Assistant does not
have, or that reads in kW where watts are taken. A missing entity whose
key is in `bad` is a "bad" note, which blocks the setup wizard."""
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard's last step
# --------------------------------------------------------------------------- #
# Below this a battery or solar reading counts as idle for a sign check.
IDLE_W = 50.0
# Meter + solar + battery is the house's use, never below zero. Below minus
# this, read seconds apart, a direction is reversed (a battery giving power).
BALANCE_W = 300.0
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
# MQTT) is only known live when the running options read the same one.
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
def power_request_entities(opts: dict) -> list:
"""The power request entity of each inverter these options name: the flat
one, or each entry's. Blank ones are left out."""
entries = roster_entries(opts)
if not entries:
values = [opts.get("setpoint_entity")]
else:
values = [e.get("setpoint_entity") for e in entries
if isinstance(e, dict)]
return [str(v).strip() for v in values
if isinstance(v, str) and v.strip()]
def _reading(entities, entity, convert=False):
"""(number, unit) of an entity's state, or (None, state text). The number
is as the add-on reads it: raw for the meter and battery power, which it
takes as watts, and in W for solar (`convert`), which pv.py converts."""
row = (entities or {}).get(entity) or {}
unit = str(row.get("unit") or "")
try:
value = float(row.get("state"))
except (TypeError, ValueError):
return None, str(row.get("state") or "nothing")
if value != value or value in (float("inf"), float("-inf")):
return None, str(row.get("state"))
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
def _signed(w) -> str:
return ("-" if w < 0 else "") + messages.watts(w)
def _cards(opts: dict) -> list:
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
entries = roster_entries(opts)
if not entries:
return [("", {k: opts.get(k) for k in (
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
out = []
for i, entry in enumerate(entries):
if isinstance(entry, dict):
name = _unit_label(opts, i).strip().strip("()")
out.append((f" for {name}" if name else "", entry))
return out
def setup_checks(opts: dict, *, entities: dict | None, running=None,
meter_now_w=None, prices_known=None) -> list[Note]:
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
Live checks on the proposed options: the meter reads, each power request
entity exists in Home Assistant (a write to a missing one is answered 200,
SAFETY-77, so only the entity list can tell), the battery levels read, and
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
request or meter entity, which the wizard will not save. Reads nothing itself:
`entities` is Home Assistant's list by id (None when it did not answer),
`running` the options this process started with, `meter_now_w` its meter
reading and `prices_known` how many of today's hours have a price.
"""
notes: list[Note] = []
if entities is None:
# Red: only the list can tell a missing power request entity (SAFETY-77).
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
else:
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
_entity_notes(notes, opts, entities,
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
meter = _meter_check(notes, opts, entities, running, meter_now_w)
batts = [_card_checks(notes, who, entry, entities)
for who, entry in _cards(opts)]
solar = []
if entities is not None:
flipped = pv.inverted(opts)
for entity in pv.entities(opts):
w, _unit = _reading(entities, entity, convert=True)
if entity not in entities or w is None:
solar.append(None)
continue
if w < -IDLE_W and entity not in flipped:
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
known = [meter] + batts + solar
if solar and batts and all(w is not None for w in known) \
and sum(known) < -BALANCE_W:
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
_price_check(notes, opts, running, prices_known)
return notes
def _meter_check(notes, opts, entities, running, meter_now_w):
"""The meter line. Returns the grid reading in W (positive: taken from
the grid) as the add-on will read it, or None when it is not known."""
source = str(opts.get("meter_source") or "off").strip()
if source == "off":
parts = [(opts.get("meter_entity"), 1.0)]
elif source == SOURCE_HA_SIGNED:
parts = [(opts.get("p1_net_entity"), 1.0)]
elif source == SOURCE_HA:
parts = [(opts.get("p1_import_entity"), 1.0),
(opts.get("p1_export_entity"), -1.0)]
else:
same = isinstance(running, dict) and all(
running.get(k) == opts.get(k) for k in _METER_KEYS)
if same and meter_now_w is not None:
_meter_line(notes, float(meter_now_w))
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
return None
if entities is None:
return None
total = 0.0
for entity, sign in parts:
entity = str(entity or "").strip()
if not entity or entity not in entities:
return None # a refusal or a missing-entity note says so
w, text = _reading(entities, entity)
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
total = -total
_meter_line(notes, total)
return total
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
def _card_checks(notes, who, entry, entities):
"""The checks for one inverter's card. Returns its battery power in W,
positive while giving power, or None when it is not known."""
if entities is None:
return None
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
else:
doing = "giving power" if w > 0 else "charging"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
return w
return None
def _price_check(notes, opts, running, prices_known) -> None:
provider = str(opts.get("price_provider") or "off").strip()
profile = str(opts.get("static_profile") or "").strip()
if provider in ("", "off"):
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
def _margin_problems(opts: dict) -> list[str]:
"""Rule 12: `peak_margin_w` is a whole number in the schema's range, and
below the peak limit, which would leave nothing to aim at."""
raw = opts.get("peak_margin_w")
margin = _number(opts, "peak_margin_w",
PEAK_OPTION_DEFAULTS["peak_margin_w"])
lo, hi = PEAK_MARGIN_RANGE_W
if margin is None or margin != int(margin) or not lo <= margin <= hi:
return [f"{S('peak_margin_w')} must be a whole number from {lo} to "
f"{hi} W. It is {messages.shown(raw)}."]
cap = _number(opts, "peak_cap_w", None)
if cap is not None and margin >= cap:
return [f"{S('peak_margin_w')} is {margin:.0f} W, not below the "
f"{S('peak_cap_w')} of {cap:.0f} W. Lower the margin."]
return []
TRUST_RANGE_PCT = (30, 100)
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
def _forecast_problems(opts: dict) -> list[str]:
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
each forecast entity set is a sensor. OPT-17: a day names its forecast
in the single key or in the list, not both."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
lo, hi = TRUST_RANGE_PCT
if trust is None or trust != int(trust) or not lo <= trust <= hi:
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
f"to {hi} %. It is {messages.shown(raw)}.")
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
e = opts.get(one)
if e is not None and e != "" and (
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
out.append(f"{S(one)} must be a sensor, like "
f"sensor.energy_production_today_remaining. It is "
f"{messages.shown(e)}.")
raw = opts.get(many)
if raw is None:
raw = []
if not isinstance(raw, list):
out.append(f"{S(many)} must be a list of sensors. It is "
f"{messages.shown(raw)}.")
continue
listed = [x for x in raw if x is not None
and not (isinstance(x, str) and not x.strip())]
for x in listed:
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
out.append(f"{S(many)} must list only sensors, like "
f"sensor.energy_production_today_remaining. "
f"It has {messages.shown(x)}.")
if e and listed:
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
f"list and clear the single one.")
return out
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+4 -5
View File
@@ -670,11 +670,10 @@ class SignWatchdog:
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
# and it defaults to the wording that shipped - "a meter reading" - so a
# one-inverter install's `reason` is byte-identical to what an operator
# (and test_watchdog.py) has always read. A per-unit instance names what
# it actually compared instead, because "commanded +1500 W against a
# meter reading -1500 W" would be a false statement about which
# instrument produced the evidence, on the one line somebody woken by
# this alarm reads first.
# has always read (test_watchdog.py pins its meaning). A per-unit
# instance names what it actually compared instead, because "commanded
# +1500 W against a meter reading -1500 W" would be a false statement
# about which instrument produced the evidence.
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "

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