Compare commits

..
Author SHA1 Message Date
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
30 changed files with 5308 additions and 146 deletions
+1
View File
@@ -7,6 +7,7 @@ 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.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8.
- **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.
@@ -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.
@@ -60,6 +60,12 @@ What UX-02 made authoritative stays authoritative:
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.
## 4. What this costs
+17 -9
View File
@@ -295,7 +295,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.
@@ -550,15 +550,23 @@ 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.
⚠️ **One inverter only.** With several inverters this hold is off. There, the
battery level limit moves an empty battery's share to the others, but only if
its “Battery cut-out level” matches where the battery really stops. A battery
that refuses at another level, or for another reason (cold, a battery fault),
can still hold its last request until the grid starts to export.
**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. The event log reads `BATTERY REFUSES …` and
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures.
*For installers:* SAFETY-52 and SAFETY-73. 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
+6
View File
@@ -226,6 +226,12 @@ 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
+9 -2
View File
@@ -132,8 +132,15 @@ class HomeAssistant:
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s", err)
# 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)
File diff suppressed because it is too large Load Diff
+19
View File
@@ -317,6 +317,18 @@ class Inverter:
tripped=bool(data.get(self.key("delivery_tripped"), False)),
reason=str(data.get(self.key("delivery_reason"), "") or ""))
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# Used at N>1 only.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -411,6 +423,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.
+225 -8
View File
@@ -28,6 +28,7 @@ import contextlib
import functools
import json
import logging
import math
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -54,6 +55,7 @@ from .clamp import (
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
)
from . import freshness
from . import homeview
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
@@ -234,6 +236,20 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w)
# SAFETY-73 review: the evidence must also span this long, not only
# SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s
# from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s,
# shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the
# plant's dead time), and both inverters of a site were then refused after
# ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
def _refused_interval(sign: int) -> tuple:
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner.
@@ -1047,6 +1063,9 @@ class Controller:
# because `self.peak_guard` is a different cycle's decision by the time
# the status page renders.
self.arb_headroom_w = None
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -1121,6 +1140,19 @@ class Controller:
"""
t = self.tuning
standing = inv.standing_w()
if len(self.inverters) > 1:
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
inv, 1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
f"{standing:+.0f} W")
return
if (len(self.inverters) == 1 and (self.auto or self.shadow)
and not self.batt_refused
and self.sat_count >= t.saturation_cycles
@@ -1132,6 +1164,116 @@ class Controller:
f"stale reading {inv.batt_fresh.held:+.0f} W "
f"while asked {standing:+.0f} W")
# --- SAFETY-73: the same refusal, per inverter, at N>1 -------------------
# SAFETY-52's decisions applied to one unit of several: a unit that gives
# < REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in
# that direction by a P_SAFETY unit limit, so the allocator hands its share
# to the others; it is probed at PROBE_W every PROBE_EVERY_S, one unit at a
# time. The ask is PROBE_W, not `saturation_w`: a unit's share is often
# small (a 400 W night load split two ways) and SAFETY-43 would otherwise
# name the empty battery as not delivering. DECISIONS/SAFETY-73.
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
inv.refused, inv.refused_at = sign, self._now_mono
inv.probe_from, inv.ref_count = None, 0
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
hint=messages.hint("unit_refuses", unit=inv.name))
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
"other inverters take over: BATTERY_REFUSES.", inv.name)
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
# Only a refusal to GIVE: a full battery that stops taking charge is
# above its cut-out level by definition and says nothing about it.
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
and float(inv.soc) > float(inv.bms_soc_floor)):
# Owner decision 3: a refusal above the configured cut-out level
# may mean that level is set wrong. One line, no safety stop.
_LOG.warning("%s refuses power above its battery cut-out level, "
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
inv.name)
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
"%.0f %%, cut-out level %.0f %%.", inv.name,
float(inv.soc), float(inv.bms_soc_floor))
def _unit_refusal_end(self, inv, why: str) -> None:
if not inv.refused:
return
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
inv.ref_count = 0
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
hint=messages.hint("unit_gives", unit=inv.name))
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
"%s.", inv.name, why)
# The site target may stand above what was placed (the refused share
# was unmet). Restart it from the watts on the wire, or the reopened
# unit is handed the parked remainder at once (phase A: -1059 W).
placed = float(sum(i.standing_w() for i in self.inverters))
self.target = self.i_w = placed
self.sat_count = 0
def _unit_probe(self, inv) -> bool:
"""True while `inv` may try PROBE_W. One unit probes at a time."""
now = self._now_mono
if not inv.refused or inv.refused_at is None or now is None:
return False
if inv.probe_from is not None:
if now - inv.probe_from < _ctl.PROBE_S:
return True
inv.probe_from, inv.refused_at = None, now
return False
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
i.probe_from is not None for i in self.inverters):
return False
inv.probe_from = now
return True
def _unit_refusals(self) -> None:
"""Engage and end each unit's refusal from its own reading."""
for inv in self.inverters:
b = inv.batt
if inv.refused:
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
else "the battery gives power")
continue
# The other direction: the battery takes PROBE_ANSWER_W or more
# the other way (charge at the floor) for SETTLE_CYCLES. Counted
# on its own reading, so one cycle of the loop overshooting
# through zero does not hand it its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if b is None:
continue # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle
# (a flapping load) reads ~0 through the plant's own lag, and that
# is a battery following, not refusing (TEST-19 flapping family).
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
if sign != inv.ref_sign or not inv.ref_count:
inv.ref_count, inv.ref_since = 0, self._now_mono
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
self._unit_refusal_start(
inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
f"for {_ctl.SETTLE_CYCLES} cycles")
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
signs = {inv.refused for inv in self.inverters}
return signs.pop() if len(signs) == 1 and 0 not in signs else None
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
@@ -2516,7 +2658,8 @@ class Controller:
now = datetime.now(timezone.utc)
return [n for n in
[x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note())]
for x in (inv.link_note(now), inv.stale_note(),
inv.refusal_note())]
if n]
async def cycle(self) -> None:
@@ -2810,7 +2953,13 @@ class Controller:
# what part 6 grades instead is that WIDENING the window
# changes the answer.
live=inv.is_live(now)[0],
parked=inv.sign_wd.tripped,
# SAFETY-73: a refusing unit is asked 0 W on purpose, and
# one asked under PROBE_W that gives under 100 W is a
# refusal too small to call (its sibling covers the meter).
parked=inv.sign_wd.tripped or bool(inv.refused) or (
n_units > 1 and inv.batt is not None
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
# the site's `peak_soc_floor` either - that one bounds a
# money-saving discharge only, and grading a pack against it
@@ -3422,6 +3571,16 @@ class Controller:
self._refusal_auto = bool(self.auto)
self._refusal_end("control started" if self.auto
else "control stopped")
for inv in (self.inverters if len(self.inverters) > 1 else ()):
self._unit_refusal_end(inv, "control started" if self.auto
else "control stopped")
# SAFETY-73: each unit's own refusal, engaged and ended on its own
# reading, before the loop reads `self.target` (an end reseeds it).
_probing = {}
if len(self.inverters) > 1 and self.inputs_ok:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -3446,6 +3605,8 @@ class Controller:
if self.loop_w is not None and self.target != self.loop_w:
self._refusal_end("another writer")
probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
@@ -3476,7 +3637,10 @@ class Controller:
i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack
# limit already closes an empty battery's discharge end.
refused=self.batt_refused if len(self.inverters) == 1 else None,
# SAFETY-73: at N>1 the site law holds 0 W only while every
# unit refuses the same way. Otherwise None, the old law.
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
@@ -3486,7 +3650,8 @@ class Controller:
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
self._refusal_update(decision, loop_prev, site_batt, probe)
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe)
# --- TEL-10 ------------------------------------------------------
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
# THE CARD IS. `self.i_w` is carried between cycles and published
@@ -3585,8 +3750,44 @@ class Controller:
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(),
weights=self.pack_weights, tick=self.alloc_tick)
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick)
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
# closed limit and the split is resolved once more.
_void = [k for k, c in enumerate(claims)
if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
if not _void:
break
for k in _void:
claims[k] = Claim.limit(
"refused", P_SAFETY,
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -5114,7 +5315,8 @@ class Controller:
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False)):
(inv.stale_note(), False),
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
@@ -5508,7 +5710,7 @@ class Controller:
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
return {
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first. The page does not show it
@@ -5517,6 +5719,21 @@ class Controller:
# peak guard's projection) are written nowhere else.
"events": list(self.events),
}
# UX-13 P1: the Home screen's structured half. Read-only, and it may
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
self._home_failed = True
_LOG.exception("Home view could not be built, so the page "
"shows the plain status rows only")
else:
_LOG.debug("Home view failed again", exc_info=True)
out["home_error"] = True
return out
async def handle_action(self, what: str):
now = datetime.now(timezone.utc)
+19
View File
@@ -786,6 +786,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",
@@ -805,6 +813,8 @@ WHY_WINNER = {
# 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",
@@ -815,6 +825,7 @@ WHY_LIMIT = {
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -857,6 +868,8 @@ HINTS = {
"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",
@@ -1027,6 +1040,12 @@ def status_samples() -> list:
("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)),
]
+218
View File
@@ -0,0 +1,218 @@
/* 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}
.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}
+375
View File
@@ -0,0 +1,375 @@
"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 () {
const T = JSON.parse(document.getElementById("ui-text").textContent).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 kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
const eur = (v) => "€" + 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", { 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();
}
async function refresh() {
if (busy) return;
busy = true;
try { S = await getStatus(); render(); }
catch (e) { offline(); }
finally { busy = false; }
}
/* ---------------- shell ---------------- */
function banner(el, level, title, sub, role) {
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, sub ? h("small", {}, sub) : null)));
}
function offline() {
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render() {
renderPill();
const structured = S.now && S.plan && !S.home_error;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", 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);
renderDetails(!structured);
renderBatteries();
}
/* ---------------- 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) {
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" : "");
$("#ctl-text").textContent = structured ? S.control.text : "";
$("#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 (placeholder until P3) ---------------- */
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
}
/* ---------------- 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.
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) };
/* ---------------- 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();
})();
+148
View File
@@ -0,0 +1,148 @@
<!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="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="bcards"></div>
<div class="card">
<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>
</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>
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
<div id="set-banner" aria-live="polite"></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">
<h2 data-t="setup.name"></h2>
<p class="small muted" data-t="setup.description"></p>
<a class="btn" href="config" data-t="settings.open"></a>
</div>
</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>
+561
View File
@@ -0,0 +1,561 @@
"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. */
(function () {
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
const W = ROOT.settings;
const { h, ask, toast, maint } = 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 active = false;
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) render(); 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;
}
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 };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
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 = keyText(f, card);
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 && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + 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)), 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);
}
paint();
window.scrollTo(0, y);
}
/* ---------------- 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 = $("#set-search").value.trim().toLowerCase();
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) 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 = !(active && 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); }
async function validate() {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V }) });
const b = await r.json();
if (seq !== vseq) return;
MSG = b.messages || [];
DEVICE = b.device_note || "";
paint();
} catch (e) { /* 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=en", { 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();
render();
validate();
}
function firstBad() {
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
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 });
}
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 = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V, fingerprint: FP }) });
const b = await r.json().catch(() => ({}));
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
FP = b.fingerprint || FP;
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
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 = ""; } });
document.addEventListener("ux13:tab", (e) => {
active = e.detail === "settings";
if (active && !M) load(); else savebar();
});
// app.js chose the first tab before this file ran.
if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); }
})();
+125 -1
View File
@@ -29,10 +29,134 @@
"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"}
"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"}
},
"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."},
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"},
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
},
"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, and the simple view follows in a later update.",
"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",
"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"},
"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."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
}
}
}
+12 -4
View File
@@ -43,6 +43,8 @@ class Field:
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)
@property
def text(self) -> str:
@@ -73,17 +75,20 @@ _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, wizard=2),
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, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
wizard=2),
)
GROUPS = (
@@ -123,7 +128,9 @@ GROUPS = (
)),
Group("tariff", (
Part("", (
Field("ui.contract", SEGMENTED, wizard=4),
# 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"),),
@@ -131,7 +138,8 @@ GROUPS = (
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, 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,
+31 -12
View File
@@ -21,11 +21,23 @@ from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
"arbitrage_from_batteries", "DeriveError"]
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say."""
"""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=""):
super().__init__(text)
self.code, self.field = code, field
# 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"}
# --------------------------------------------------------------------------- #
@@ -80,13 +92,16 @@ 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")
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")
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")
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)
@@ -96,8 +111,9 @@ def _roster_encode(m, keep):
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":
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
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
@@ -210,7 +226,8 @@ def _tariff_encode(m, keep):
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")
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:
@@ -226,17 +243,19 @@ def _tariff_encode(m, keep):
elif kind == "custom":
if "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab")
"Configuration tab", "custom_profile",
"ui.contract")
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}")
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")
raise DeriveError(f"{name} needs a price", "no_price", name)
return value
@@ -259,7 +278,7 @@ def _region_encode(m, keep):
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}")
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:
+280
View File
@@ -0,0 +1,280 @@
"""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 io
import json
import os
import re
from . import uilayout as L
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
# A choice the page cannot make, only keep: odd tariff windows (P5 editor).
ONLY_IF_HELD = ("custom",)
_TIERS = ("bad", "warn", "info")
def ui_text() -> dict:
with io.open(UI_TEXT, encoding="utf-8") as f:
return json.load(f)
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,
"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.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()
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)."""
out = U.ui_to_options(model)
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) -> dict:
words = ui_text()["settings"]["derive"]
text = words.get(err.code, words["unknown"])["description"]
return {"field": err.field or None, "card": None, "tier": "bad",
"text": text}
# --------------------------------------------------------------------------- #
# 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))
+91 -84
View File
@@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
`/` is the status and control surface and is unchanged. `/config` is the
`/` is the status and control surface; since UX-13 P1 its files are in
app/static/ and its Home screen reads the structured half of `/status`
(app/homeview.py). `/config` is the
configuration surface: its markup lives in app/config_page.py, its field model
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
stays HERE is the routing, so that the question a reviewer has to answer - does
@@ -28,96 +30,52 @@ actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
of Home Assistant's entity list (app/haentities.py) and writes nothing.
"""
import io
import json
import logging
import os
from aiohttp import web
from . import messages, selfconfig
from . import messages, selfconfig, uipage
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe RS485 Controller</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
*{box-sizing:border-box}
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
h1{font-size:18px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
tr:last-child td{border-bottom:0}
.k{color:var(--muted);font-weight:400}
ul{margin:6px 0 0;padding-left:18px}
li{margin:3px 0}
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/en.json. They are
# joined into ONE response here, once, so the page needs no second request
# and no route of its own: the route list below is unchanged, and every URL
# the page uses stays relative (ingress). No build step, no external file.
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
<div class="card">
<table id="live"></table>
</div>
<div class="card">
<div class="k">Commissioning</div>
<ul id="checks"></ul>
</div>
def _read(path):
with io.open(path, encoding="utf-8") as f:
return f.read()
<div class="card">
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
<button onclick="act('maint_start')">Force maintenance cycle</button>
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
<div class="muted" id="hint"></div>
</div>
<script>
async function refresh(){
try{
const r = await fetch('status', {cache:'no-store'});
const s = await r.json();
const b = document.getElementById('banner');
b.textContent = s.banner;
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
document.getElementById('live').innerHTML = s.rows
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
document.getElementById('checks').innerHTML = s.checks
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
.join('');
document.getElementById('hint').textContent = s.hint || '';
}catch(e){
document.getElementById('banner').textContent = 'cannot reach the add-on';
}
}
async function act(what){
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: what})});
refresh();
}
refresh(); setInterval(refresh, 3000);
</script></body></html>
"""
def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
scripts = {}
for name in ("app.js", "settings.js"):
scripts[name] = _read(os.path.join(STATIC, name))
if "</script" in scripts[name].lower():
raise ValueError(f"{name} may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", scripts["app.js"])
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -144,9 +102,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
return web.Response(text=index_page, content_type="text/html")
async def status(_request):
return web.json_response(controller.status_payload())
@@ -166,15 +125,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_index(_request):
return web.Response(text=CONFIG_PAGE, content_type="text/html")
async def _proposed(request):
async def _proposed(request, stored=None):
"""(body, options, model). UX-13's settings view posts its page
`model`, which app/uimodel.py turns into the options blob here, so the
validator and the Supervisor only ever see a real blob. The classic
page posts `options`. Raises DeriveError for a model no blob can say."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
return body, uipage.to_options(model, stored), model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
return body, options
return body, options, None
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -203,6 +169,13 @@ def build_app(controller, selfcfg=None) -> web.Application:
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
# screens, which show the year beside the region choice.
"region": region_of(options),
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
})
return web.json_response(model)
@@ -219,7 +192,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
product this card is delivering; anything this layer did to them would
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
try:
_body, options, model = await _proposed(request)
except DeriveError as err:
return web.json_response({"problems": [], "by_key": {},
"unanchored": [], "notes": [],
"messages": [uipage.derive_message(err)]})
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -232,6 +210,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = uipage.anchors(problems, notes, model,
_keys_of(options))
return web.json_response(payload)
def _keys_of(options):
@@ -244,7 +225,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def config_save(request):
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
body, options = await _proposed(request)
# The page model carries no stored values of the keys it shows, so the
# blob is settled against what is stored: an unchanged value is written
# exactly as stored (uipage.to_options).
try:
posted = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError as err:
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": [uipage.derive_message(err)]},
status=422)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
@@ -254,6 +255,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
stored = refused.stored or {}
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"changed_fields": (uipage.fields_of(refused.changed,
model)
if model is not None else []),
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
@@ -263,9 +267,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
return web.json_response(
{"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}, status=422)
payload = {"kind": "invalid", "problems": refused.problems,
"by_key": by_key, "unanchored": unanchored}
if model is not None:
payload["messages"] = uipage.anchors(refused.problems, [],
model, _keys_of(options))
return web.json_response(payload, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
+8 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -189,16 +189,20 @@ UNIT_SOURCES = {
"is added, `peak_soc_floor` is not consulted, the SITE target "
"is untouched, and main.py does not file it at all at a "
"roster of one",
# SAFETY-73.
"refused": "THIS battery's own reading gave nothing against THIS unit's "
"own standing command, so that direction is closed on THIS "
"unit and its share goes to the others. Filed at N>1 only",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all ELEVEN claim sources are classified, and only those eleven",
check("all TWELVE claim sources are classified, and only those twelve",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
check("the six that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -216,7 +220,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the five "
check("with TWO the identity breaks and the split becomes real: the six "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
+10 -1
View File
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app import control, messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -593,6 +593,15 @@ check("...and the SHARES now differ, which is OPT-09 and not a weakening of the
"grades the ALARM, and neither can do the other's half",
_pw != _pf
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
# SAFETY-73. A unit that gives < 100 W while asked > 200 W is refused after
# SETTLE_CYCLES cycles that also span REFUSED_SPAN_S (22 s). This fixture's 14
# cycles are 0.2 s apart, so the wedged B meets the count and not the span, and
# SAFETY-42's report stands alone here. test_refusal_units.py grades the
# refusal. On a site the 185 s hold puts the refusal first.
check("SAFETY-73: the wedged arm's B is not refused in this fixture: 12 cycles "
"of evidence in under 3 s are not the 22 s a refusal needs",
_cw.inverters[1].refused == 0
and _cw.inverters[1].ref_count >= control.SETTLE_CYCLES)
check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
+21
View File
@@ -10,6 +10,7 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
import asyncio
import json
import logging
import sys
import time
@@ -96,9 +97,29 @@ for name, handler in (("502 while HA restarts", bad_gateway),
("a body that is not JSON", garbage),
("JSON that is not a list", not_a_list)):
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
class _Lines(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
if record.levelno >= logging.WARNING:
self.lines.append(record.getMessage())
_lines = _Lines()
logging.getLogger().addHandler(_lines)
check("an answer slower than the timeout -> None, at the timeout",
asyncio.run(read_with(slow, timeout_s=0.3)) is None
and read_with.took < 1.0)
# UX-13 P1 test finding (c): a timeout's own text is empty, which left
# "Reading the entity list failed: " with no reason.
check("...and its WARNING line gives the reason, not an empty one "
f"({_lines.lines})", any(
x == "Reading the entity list failed: no answer within 0.3 seconds."
for x in _lines.lines)
and not any(x.rstrip().endswith("failed:") for x in _lines.lines))
logging.getLogger().removeHandler(_lines)
check("no server at all -> None", asyncio.run(closed_port()) is None)
check("the timeout for the list is the same 10 s as every other read",
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
+643
View File
@@ -0,0 +1,643 @@
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
`hint`, `events`) AND carries the structured half the Home screen reads:
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
Both are pinned here, by name and shape.
2. The plan shows only what the add-on does today. Trading on price is the
verdict the control cycle would reach (the same `plan.arbitrage()` call),
drawn where the cheapest and dearest prices are, with kWh and € per block.
The monthly cycle, the peak limit and "follows the house" are the rest.
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
alarm under "Needs attention".
4. A fault in the Home view never costs the page its old rows.
5. The page: served at `/` from app/static, no external file, relative URLs
only, every label resolves in app/ui_text, and the region year reaches
`config/model` for the P2 Settings screens.
The STYLE scan of every Home sentence is test_messages.py part 8.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import io
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from aiohttp import ClientSession, web as aioweb
from app import homeview, web
from app.main import Controller
from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead
from app.static_tariff import TariffPlan
from app.uimodel import REGION_PRESETS
from named_absence import field, nth, present
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --------------------------------------------------------------------------- #
# Fixtures: a real Controller, with its readings set directly.
# --------------------------------------------------------------------------- #
class _Store:
def __init__(self):
self.data = {}
def set(self, k, v):
self.data[k] = v
def get(self, k, d=None):
return self.data.get(k, d)
def set_time(self, k, v):
self.data[k] = v.isoformat()
def get_time(self, k):
v = self.data.get(k)
return None if v is None else datetime.fromisoformat(v)
class _Pub:
enabled = False
def publish(self, _v):
pass
def close(self):
pass
class _Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def set_number(self, _e, _v):
return True
async def call(self, *_a):
return True
async def states(self):
return []
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
"maintenance_enabled": False}
TWO = dict(ONE, inverters=[
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
"capacity_kwh": 9.6},
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
"capacity_kwh": 5.4}])
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
auto=True, wrote=0.0):
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt = soc[i], batt[i]
inv.last_write = wrote
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = grid
return c
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
pts = []
for d in range(days):
for k, p in enumerate(prices):
start = day0 + timedelta(days=d, hours=k)
pts.append(PricePoint(start, p, provisional_from is not None
and start >= provisional_from))
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
"generic_dayahead", NOW), None,
"provider")
def quiet_guard(projected_w=1240.0):
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
ceiling_from="cap", projected_w=projected_w)
def status(c, now=NOW):
s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half
# at the fixture's instant so the day and the hours are the mockup's.
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
return s
# --------------------------------------------------------------------------- #
print("1. /status: the old fields stay, the structured half is added")
# --------------------------------------------------------------------------- #
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
s1 = c1.status_payload()
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
"control"}
check(f"every old field is still there, with its old type ({sorted(OLD)})",
OLD <= set(s1) and isinstance(s1["rows"], list)
and all(len(r) == 2 for r in s1["rows"])
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
and isinstance(s1["events"], list))
check(f"...and the structured half is added beside them ({sorted(NEW)})",
NEW <= set(s1) and "home_error" not in s1)
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
== json.loads(json.dumps(s1)))
check("`state` names the kind, the pill word, the control row and a sub-line",
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
and s1["state"]["kind"] == "running")
check("`now` carries the four tiles in W and a plain line for each",
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
<= set(s1["now"])
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
has_solar=True, shadow=False)
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
has_solar=True, shadow=False)
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
has_solar=True, shadow=False)
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
"= 1500 W",
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
check("...a sum below zero is not shown as a positive figure: 0 W inside "
"the idle band, and no figure beyond it",
_nz["house_w"] == 0.0 and _nh["house_w"] is None
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
check("...and with no solar reading the house figure is NOT invented: it is "
"None and the tile says why",
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
_b = s1["batteries"][0]
check("`batteries[]` has level, power, state, mode, the now line and why",
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
and _b["now"] == "Giving 1.0 kW now.")
_p = s1["plan"]
check("`plan` carries the verdict, the prices, the runs, the lines and "
"the region",
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
and len(_p["prices"]) in (23, 24, 25))
check("`attention[]` has severity, title, body and docs; `month_peak` and "
"`control` are there",
all({"severity", "title", "body", "docs"} <= set(a)
for a in s1["attention"])
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
<= set(s1["month_peak"])
and {"running", "shadow", "text", "maintenance", "docs_url"}
<= set(s1["control"]))
# --------------------------------------------------------------------------- #
print("\n2. the plan is what the add-on actually does")
# --------------------------------------------------------------------------- #
s_house = status(make(TWO))
check("with trading off and no cycle due, each battery follows the house "
"from now to the end of the day, and nothing else is drawn",
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
and s_house["plan"]["verdict_key"] == "house"
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
for r in s_house["plan"]["runs"])
and s_house["plan"]["runs"][0]["to"] == "24:00")
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
c_arb = make(ARB)
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
c_arb.peak_guard = quiet_guard()
s_arb = status(c_arb)
_runs = s_arb["plan"]["runs"]
_grid = [r for r in _runs if r["kind"] == "grid"]
_give = [r for r in _runs if r["kind"] == "give"]
check("trading on price: a charge block at the cheapest hour (13:00) and a "
"give block at the dearest (19:00), on each battery",
[(r["battery"], r["from"], r["to"]) for r in _grid]
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
and [(r["battery"], r["from"], r["to"]) for r in _give]
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
and s_arb["plan"]["verdict_key"] == "trade")
check("...placed where the control cycle's own verdict puts them: the same "
"plan.arbitrage() call on the same plan",
c_arb.fresh_tariff_plan(NOW).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
.Uncertainty.NO_WEATHER}))
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
refused=lambda v: None)
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
check("each block carries kWh and €: charging is capped by the battery's own "
"rating and the room its level leaves, € = kWh x that hour's price",
[r["kwh"] for r in _grid] == [2.5, 2.5]
and [r["eur"] for r in _grid] == [0.35, 0.35]
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
and "€0.35" in _grid[0]["amount"])
# Giving at 19:00 comes after the 13:00 charge, so each battery may give what
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
check("...and giving is capped by what each battery holds above arbitrage's "
"floor after the charge before it",
[r["kwh"] for r in _give] == [2.5, 2.5]
and [r["eur"] for r in _give] == [0.9, 0.9])
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
c = make(opts, soc=soc)
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
c.tariff_at, c.peak_guard = now, quiet_guard()
s = status(c, now=now)
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
# Review of #144: only what arbitrage.decide() would still do is drawn.
_t, _l = trade((96.0, 35.0))
check("a battery at arbitrage's ceiling gets no charge block, not "
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
# the dearest later today instead, with the emptiest pack at the floor.
_late = MOCK[:22] + [.40, .24]
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
check("no give block while the emptiest battery is at arbitrage's floor: "
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
not [k for k in _t if k[0] == "give"]
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
check("no give block at a negative price (the cycle refuses that sale)",
not [k for k in _t if k[0] == "give"]
and [k for k in _t if k[0] == "grid"])
# Running, a missing level is a red row and the site is held. In shadow mode
# the plan is still drawn, so this is where the level gate shows.
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
check("with a battery level unknown the cycle does not trade, so nothing is "
"drawn and a line says why (shadow mode, which still plans)",
_t == [] and homeview.line_no_trade("no_level") in _l)
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
check("a plan with no published prices yet says so, not 'the price gap is "
"too small'",
_t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not in _l)
check("the price bars are today's 24 hours, from the real series, with the "
"cheap and expensive thirds marked",
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear"
and s_arb["plan"]["prices"][0]["kind"] == "mid"
and s_arb["plan"]["prices"][13]["label"] == "13:00")
check("the price line names the price now, today's lowest and highest, and "
"that tomorrow's prices are in",
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
"Today: lowest €0.14 at 13:00, "
"highest €0.36 at 19:00.")
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
c_late = make(ARB)
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
c_late.peak_guard = quiet_guard()
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
"today",
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
c_peak = make(ARB)
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
ceiling_w=3500.0, ceiling_from="cap",
projected_w=3900.0)
s_peak = status(c_peak)
check("while the peak limit holds, the verdict refuses as the cycle does: no "
"trade block, a plain line, and a peak block for this 15-minute period",
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
c_pk = make(ARB)
c_pk.p1_enabled = True # the peak line is shown where P1 runs
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
ceiling_from="month_peak", projected_w=1240.0)
s_pk = status(c_pk)
check("the plan's peak line names the guard's ceiling, which is this month's "
"peak once it is above the limit",
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
c_blind = make(ARB)
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
s_blind = status(c_blind) # meter_source off: the guard never runs
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
"the cycle refuses it (guard state None)",
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
c_flat = make(ARB)
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
c_flat.peak_guard = quiet_guard()
s_flat = status(c_flat)
check("a flat day: no trade, no cheap or expensive hour marked",
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
c_old = make(ARB)
c_old.tariff_plan = provider_plan()
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
c_old.peak_guard = quiet_guard()
s_old = status(c_old)
check("a stale plan is not traded on by the cycle, so it is not drawn",
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
c_m = make(MAINT)
s_m = status(c_m)
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
and s_m["plan"]["verdict_key"] == "cycle"
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
check("...with its duration from the battery's capacity and the cycle's rates "
"and hold time",
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
c_m.maints[0].store.set("phase", "drain")
s_md = status(c_m)
check("a cycle under way is drawn from now, and the battery's now line says "
"so",
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
== ["11:40"]
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
# --------------------------------------------------------------------------- #
print("\n3. shadow mode, a stopped site, an alarm")
# --------------------------------------------------------------------------- #
c_sh = make(dict(ARB, shadow_mode="on"))
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
c_sh.peak_guard = quiet_guard()
s_sh = status(c_sh)
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
"every block and the head say 'would'",
s_sh["state"]["kind"] == "shadow"
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
and all("ould" in x for x in _texts)
and s_sh["plan"]["head"].startswith("Shadow mode:"))
check("...and 'Shadow mode is on' is the first item under Needs attention",
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
and s_sh["attention"][0]["severity"] == "info")
check("...and each battery's now line says it is held at 0 W",
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
s_stop = status(make(TWO, auto=False))
check("stopped: nothing is planned and the head says so",
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
and s_stop["plan"]["verdict_key"] == "stopped"
and s_stop["control"]["running"] is False
and all(b["now"] == "Held at 0 W while control is stopped."
for b in s_stop["batteries"]))
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
c_al.inverters[0].sign_wd.tripped = True
s_al = c_al.status_payload()
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
check("an alarm: level bad, state alarm, and the red row is the first item "
"under Needs attention",
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
and _red and s_al["attention"][0] is _red[0]
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
check("...and nothing is planned while every inverter is held at 0 W",
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
check("...and every red, amber and settling row reaches Needs attention with "
"its own sentence as the body, and no green row does",
[a["body"] for a in homeview.attention(s_al["checks"])]
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before
# the inverter's power request reached 0 W. Until the last accepted request
# is 0 W it says "Going to 0 W".
c_al.inverters[0].last_write = 20.0
s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
c_al.inverters[0].last_write = 0.0
s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
check("an alarm while the last power request is still 20 W says 'Going to "
"0 W', and 'Held at 0 W' once 0 W was accepted",
s_go["plan"]["verdict"] == "Going to 0 W"
and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears."
and s_go["plan"]["head"].startswith("Every inverter goes to 0 W")
and s_at["plan"]["verdict"] == "Held at 0 W"
and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.")
c_sp = make(TWO, auto=False)
c_sp.inverters[1].last_write = -1500.0
s_sp = status(c_sp)
s_new = status(make(TWO, auto=False, wrote=None))
check("...and before any power request was accepted (just started, or every "
"write refused) it says 'Going to 0 W', not 'Held'",
[b["now"] for b in s_new["batteries"]]
== ["Going to 0 W while control is stopped."] * 2)
check("...and a stop does the same, per battery",
[b["now"] for b in s_sp["batteries"]]
== ["Held at 0 W while control is stopped.",
"Going to 0 W while control is stopped."])
# Finding (b): a quarter-hourly feed. The block's price and the price line
# name the same 15-minute point, and nothing says "the list".
_q = []
for _k, _p in enumerate(MOCK):
for _m in range(4):
_q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m),
round(_p + (0.0 if _m != 3 else -0.01), 4), False))
_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False)
for p in list(_q)]
c_q = make(ARB)
c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD,
"generic_dayahead", NOW), None,
"provider")
c_q.tariff_at = NOW
c_q.peak_guard = quiet_guard()
s_q = status(c_q)
_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"]
_lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
check("quarter-hour prices: the charge block, its price and the price line's "
"lowest are the same 15-minute period",
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
"per kWh."
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"]))
check("...and the hour bar says it is an hour average",
s_q["plan"]["prices"][13]["points"] == 4
and '"plan.average_tip"' in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read())
# --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows")
# --------------------------------------------------------------------------- #
_real = homeview.build
try:
homeview.build = lambda *a, **k: 1 / 0
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
finally:
homeview.build = _real
check("the old fields are served and `home_error` says the rest is missing",
OLD <= set(s_err) and s_err.get("home_error") is True
and not (NEW & set(s_err)))
# --------------------------------------------------------------------------- #
print("\n5. the page")
# --------------------------------------------------------------------------- #
PAGE = web.page()
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
encoding="utf-8").read()
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))["home"]
def resolve(path):
node = UI
for k in path.split("."):
if not isinstance(node, dict) or k not in node:
return None
node = node[k]
return node
check("the page is assembled: no placeholder is left and the words parse",
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
and "/*UI_TEXT*/" not in PAGE
and json.loads(PAGE.split('id="ui-text">', 1)[1]
.split("</script>", 1)[0].replace("<\\/", "</"))["home"]
== UI)
check("four tabs with the four hash routes, in a keyboard tablist",
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
"settings", "setup"]
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
and "ArrowRight" in JS and "ArrowLeft" in JS)
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
check(f"no external file and no web font: no URL leaves the add-on "
f"({_ext})", not _ext and "@import" not in PAGE
and "<link" not in PAGE and "src=" not in PAGE)
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
check(f"every fetch and link is relative, for ingress ({_abs})",
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
f"keys; missing: {_missing})", len(_keys) > 30 and not _missing)
check("data is set as text, never as HTML, so no status sentence can inject "
"markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS)
check("every function of the old page is still on it: start/stop, the "
"maintenance start and abort, the status rows, the checks and the "
"event line",
"auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS
and 'id="rows"' in PAGE and 'id="checks"' in PAGE
and 'id="hint"' in PAGE and 'href="config"' in PAGE)
check("stopping, starting and both maintenance actions ask first",
all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start",
"maint_abort")))
check("tooltips open on hover, on a tap and on keyboard focus",
"pointermove" in JS and "pointerdown" in JS and "focusin" in JS)
check("the chart is not colour alone: expensive hours and blocks are "
"patterned, and every item has a text label",
"repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS)
check("the phone layout puts the tabs at the bottom under 640 px, and dark "
"mode follows the system",
"@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE
and "prefers-color-scheme:dark" in PAGE)
async def _serve():
class _Sup:
async def info(self):
return {"options": {"price_taxes_eur_kwh": FLANDERS,
"price_taxes_profile": ""},
"schema": [], "translations": {}}
c = make(ONE, soc=(50.0,), batt=(0.0,))
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
out = {}
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/") as r:
out["page"] = (r.status, r.content_type, await r.text())
async with s.get(f"http://127.0.0.1:{port}/status") as r:
out["status"] = await r.json()
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
out["model"] = await r.json()
finally:
await runner.cleanup()
return out
E = asyncio.run(_serve())
check("GET / serves the assembled page as HTML", E["page"][0] == 200
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
check("GET status over HTTP carries both halves",
OLD <= set(E["status"]) and NEW <= set(E["status"]))
check("the levies preset and its year reach config/model for the P2 "
"Settings screens (P0 review follow-up)",
E["model"].get("region") == {"id": "flanders", "year": 2026})
c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS))
c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW
s_reg = status(c_reg)
check("...and Home names the preset with its year wherever prices come from "
"a feed, and says 'your own figure' otherwise",
s_reg["plan"]["region"] == {"id": "flanders", "year": 2026}
and s_reg["plan"]["region_line"]
== "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures."
and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure."
and s_house["plan"]["region_line"] == "")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+65
View File
@@ -883,6 +883,8 @@ DATA = {
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
"hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
# UX-13 P2: the entity list's wait, and an error's text or else its name
"hass.py:STATES_TIMEOUT_S", "hass.py:str(err) or type(err).__name__",
# main.py
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
@@ -1817,6 +1819,69 @@ check("the action log counts 'engagements' with a real plural and the "
and messages.count(25, "engagement") == "25 engagements"
and messages.count(1, "engagement") == "1 engagement")
# ============================================================================== #
print("\n8. the Home screen's sentences (UX-13 P1)")
# app/homeview.py builds every sentence the Home screen shows. Each is rendered
# with real values by `text_samples()` and held to STYLE.md: a title at most 5
# words, a plan line at most 2 sentences of at most 12 words each (the design
# note's new row type), any other line 1 sentence of at most 25 words.
from app import homeview # noqa: E402
PLAN_SENTENCE_WORDS, PLAN_SENTENCES = 12, 2
def home_problems(kind, text):
if kind == "title":
return title_problems(text)
out = text_problems(text, keys=False, entities=False)
if ";" in text:
out.append("semicolon")
if kind == "line":
if sentences(text) > 1:
out.append(f"over 1 sentence ({sentences(text)})")
if len(words(text)) > 25:
out.append(f"over 25 words ({len(words(text))})")
return out
if sentences(text) > PLAN_SENTENCES:
out.append(f"plan line over {PLAN_SENTENCES} sentences")
long = [x for x in re.split(r"(?<=[.!?])\s+", text)
if len(words(x)) > PLAN_SENTENCE_WORDS]
if long:
out.append(f"plan sentence over {PLAN_SENTENCE_WORDS} words "
f"({len(words(long[0]))}): {long[0]!r}")
return out
_home = homeview.text_samples()
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p]
for k, t, p in _home_bad:
print(f" {k}: {t!r}: {p}")
check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, "
f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad)
_shadowed = [t for k, t in homeview.text_samples() if "ould" in t]
check("in shadow mode the Home plan says what the add-on WOULD do: every "
"shadow variant of a plan text carries 'would'",
all("ould" in f(True) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_give_what(2.5, sh),
lambda sh: homeview.run_cycle_what("idle", sh),
lambda sh: homeview.run_cycle_why(sh),
lambda sh: homeview.run_peak_what(3500, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.run_house_why(sh),
lambda sh: homeview.plan_head("house", sh),
lambda sh: homeview.plan_head("trade", sh),
lambda sh: homeview.line_peak_limit(3500, sh)))
and not any("ould" in f(False) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.plan_head("house", sh)))
and len(_shadowed) > 10)
check("mutation: a 13-word plan sentence and a 6-word title are caught",
home_problems("plan", "One two three four five six seven eight nine "
"ten eleven twelve thirteen.")
and home_problems("title", "One two three four five six"))
print()
if fails:
print(f"{len(fails)} FAILED")
+15 -13
View File
@@ -2655,7 +2655,7 @@ check("...and so does the persistent notification, which outlives the page and "
# wants from you, not an edit.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack"}
"sign_site", "pack", "refused"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2697,7 +2697,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2848,7 +2848,8 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack"})
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2945,7 +2946,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a",
"pack": "inverter-a", "refused": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -3027,13 +3028,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 11
len(_direct) == 13
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3050,15 +3051,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
"all six pass one. "
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
"SAFETY-73's two `refused` - and all eight pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -3067,7 +3068,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the twelve appends account for exactly the eleven "
check("...and between them the fifteen appends account for exactly the twelve "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3119,7 +3120,7 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
("supervised", "inverter-a"),
("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - SEVEN of the eleven sources need a state "
"reason the static pin exists - EIGHT of the twelve sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
@@ -3127,7 +3128,8 @@ check("...and naming those three is not an accident of this fixture but the "
"standing opposed to the meter for its whole window",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site"})
"arbitrage", "circulation", "sign_site",
"refused"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
+7 -4
View File
@@ -700,17 +700,20 @@ check(f"PRECONDITION: the scan found the whole package, not a subset - "
# UX-13 adds the settings surface: labels (messages.py), a settings warning
# (validate.py) and the page's layout and derivation (uilayout.py, uimodel.py).
# None of them decides anything, so the decision modules stay excluded.
# UX-13 P1 adds homeview.py, the Home screen's read-only view: its House tile
# is grid + solar + battery, and it shows nothing without a solar reading.
_DECIDERS = ("control.py", "arbiter.py", "allocator.py", "peakguard.py",
"arbitrage.py", "maintenance.py", "clamp.py", "freeze.py",
"watchdog.py", "delivery.py", "circulation.py")
check("⭐ ONLY THE READER, THE PUBLISHER AND THE SETTINGS SURFACE SEE THE "
"MATERIAL: `pv.py` (the reader), `main.py` (the read and the two operator "
"surfaces), `mqtt.py` (the publication), and `messages.py`, "
"`validate.py`, `uilayout.py` and `uimodel.py` (settings). "
"surfaces), `homeview.py` (the Home screen's view), `mqtt.py` (the "
"publication), and `messages.py`, `validate.py`, `uilayout.py` and "
"`uimodel.py` (settings). "
f"{', '.join(_DECIDERS)} do not mention it in any spelling, comments "
"included",
_touch == ["main.py", "messages.py", "mqtt.py", "pv.py", "uilayout.py",
"uimodel.py", "validate.py"]
_touch == ["homeview.py", "main.py", "messages.py", "mqtt.py", "pv.py",
"uilayout.py", "uimodel.py", "validate.py"]
and not set(_DECIDERS) & set(_touch))
# habit 4 in the instrument: a scanner that cannot go red is not a scanner.
check("...and the scan CAN go red - a module body with the material in it is "
+537
View File
@@ -0,0 +1,537 @@
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
SAFETY-52 per inverter. With two or more inverters, a battery that refuses
(gives < 100 W for 12 cycles and 22 s while asked > 200 W, or trips the stale-reading
gate on a ~0 W reading) is closed in that direction by its own P_SAFETY limit,
so the allocator hands its share to the others. It is probed at 200 W every
15 minutes, one inverter at a time. When every inverter refuses the same way
the site holds 0 W. When a refusal ends the site target restarts from the watts
on the wire. DECISIONS/SAFETY-73 has the record.
Drives the REAL `Controller` (read_inputs + cycle, the real freshness gate,
allocator, OPT-09 pack claim and SAFETY-42 detector) with two inverters in
simulated time. At the cut-out a battery reads -4 W for any request >= -4 W
and accepts charge. Two register shapes: FLAT at -4 W (the 09-06 capture) and
JITTER +-3 W (the gate cannot arm). `OLD` runs the same fixture with the
per-unit refusal switched off, which is 652097a's behaviour at N>1.
Mutations (MUTATION.md, `python3 -B`, copies purged of `__pycache__`), graded
over this file / test_delivery / test_refusal. Every one is red here:
# mutation reds
--- --------------------------------------------------------- -------
U1 main.py: the per-unit engage clause never fires 7/2/0
U2 main.py: the N>1 stale-reading seed returns at once 14/0/0
U3 main.py: no `refused` claim is filed 13/2/0
U4 main.py: a probe never pins (always the closed limit) 7/0/0
U5 main.py: a probe pin stands whatever the site target 1/0/0
U6 main.py: no reseed of the site target when a refusal ends 1/0/0
U7 main.py: no site hold when every inverter refuses 2/0/0
U8 main.py: the other-direction exit removed (charge counter) 1/0/0
U9 main.py: the delivery exit removed 3/0/0
U10 main.py: probes are no longer one at a time 1/0/0
U11 main.py: the above-cut-out WARNING condition inverted 1/0/0
U12 main.py: SAFETY-42's account no longer sees a small ask 1/0/0
U13 main.py: a unit engages above `saturation_w`, not PROBE_W 1/0/0
U14 main.py: the evidence need not span REFUSED_SPAN_S (review) 1/0/0
U15 main.py: the WARNING also for a refusal to take (review) 1/0/0
On 652097a itself this file stops at the first sample (`Inverter` has no
`refused`); its OLD arms reproduce that tree's N>1 behaviour instead, and
every PRECONDITION line is graded against them.
"""
import asyncio
import logging
import sys
from datetime import datetime, timedelta, timezone
from app import control
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
from app.main import Controller
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
import app.main as m
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
TICK = 5.0
CUT_W = -4.0
FULL_W = 40.0
H = 3600.0
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
UNITS = ("a", "b")
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
"""A house, one meter, and two batteries that follow unless cut out.
`lag` (review): (dead s, publish s). Each battery reading then shows the
power of `dead` s before its last publish; the power itself and the meter
follow at once. `full[u](t)`: that battery takes at most FULL_W of charge.
"""
def __init__(self, load, cut, soc, jitter, lag=None, full=None):
self.load, self.cut, self.soc, self.jitter = load, cut, soc, jitter
self.lag, self.full = lag, full or {}
self.cmd = {u: 0.0 for u in UNITS}
self.hist = {u: [(-1e18, 0.0)] for u in UNITS}
self.t = 0.0
def power(self, u, t=None):
t = self.t if t is None else t
c = next((v for tt, v in reversed(self.hist[u]) if tt <= t), 0.0)
if self.cut[u](t) and c >= CUT_W:
if self.jitter:
return CUT_W + ((int(t / TICK) % 3) - 1) * 3.0
return CUT_W
if u in self.full and self.full[u](t) and c < -FULL_W:
return -FULL_W
return float(round(c))
def batt(self, u):
if self.lag is None:
return self.power(u)
dead, pub = self.lag
return self.power(u, (self.t // pub) * pub - dead)
def grid(self):
return self.load(self.t) - sum(self.power(u) for u in UNITS)
async def limits(self, e):
return (-2000.0, 2000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
v = self.soc[u]
return v(self.t) if callable(v) else v
if e.startswith("sensor.batt"):
return self.batt(u)
return None
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
self.hist[e[-1]].append((self.t, float(v)))
return True
def _off(c):
"""652097a's N>1 behaviour: nothing refuses per unit."""
c._unit_refusals = lambda: None
c._unit_refusal_start = lambda *a, **k: None
def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
extra=None, tick=None, lag=None, full=None):
"""Real cycles every TICK of simulated time. Rows are dicts per cycle."""
async def go():
hass = _Hass(load, cut, soc or {"a": 50.0, "b": 50.0}, jitter, lag,
full)
dt = tick or TICK
inv = [{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}",
"batt_entity": f"sensor.batt_{u}",
"max_w": 2000, "bms_soc_floor": 10} for u in UNITS]
o = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": inv, "gain": 0.6, "slew_w": 400,
"target_grid_w": -10, "deadband_w": 15, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15, "auto_start": True}
o.update(extra or {})
c = Controller(o, hass, _Store(), _Pub(), stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -2000.0, 2000.0
c.auto = True
if old:
_off(c)
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
rows = []
trips = {u: 0 for u in UNITS}
was = {u: False for u in UNITS}
pending = dict(events or {})
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for et in sorted(pending):
if et <= t:
pending.pop(et)(c)
await c.read_inputs(now_mono=t)
for u, i in zip(UNITS, c.inverters):
s = i.batt_fresh.stale
trips[u] += s and not was[u]
was[u] = s
await c.cycle()
rows.append(dict(
t=t, grid=hass.grid(), site=c.target, reason=c.loop_reason,
a=hass.cmd["a"], b=hass.cmd["b"],
ra=c.inverters[0].refused, rb=c.inverters[1].refused,
da=c.inverters[0].delivery_wd.tripped,
db=c.inverters[1].delivery_wd.tripped))
t += dt
finally:
m.datetime = real_dt
return rows, trips, c
return asyncio.run(go())
def after(rows, t0, t1=None):
return [r for r in rows if r["t"] >= t0 and (t1 is None or r["t"] < t1)]
def mean(rows, k):
return sum(r[k] for r in rows) / len(rows) if rows else float("nan")
def never(t):
return False
def cut_at(t0, t1=None):
return lambda t: t >= t0 and (t1 is None or t < t1)
def probes(rows, k):
"""Start times of runs where refused unit `k` is asked exactly PROBE_W."""
out, prev = [], False
for r in rows:
on = abs(r[k]) == PROBE_W and r["r" + k] != 0
if on and not prev:
out.append(r["t"])
prev = on
return out
SHAPES = (("flat", False), ("jitter", True))
# ==============================================================================
print("1. case a: house 3000 W, both give power, A cuts out at 120 s")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, otr, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
held = [r for r in tail_n if abs(r["a"]) != PROBE_W]
print(f" {shape}: old B {mean(tail_o, 'b'):.0f} W grid "
f"{mean(tail_o, 'grid'):+.0f} W trips A {otr['a']}; new B "
f"{mean(tail_n, 'b'):.0f} W grid {mean(tail_n, 'grid'):+.0f} W "
f"trips A {ntr['a']}")
check(f"{shape}: PRECONDITION: on the old law B never takes over (mean "
f"under 1950 W) while A gives nothing",
mean(tail_o, "b") < 1950 and mean(tail_o, "grid") > 1050)
check(f"{shape}: A is refused and asked 0 W outside its probes, B carries "
f"its rating, and the grid imports only what B cannot give",
cn.inverters[0].refused == 1 and all(r["a"] == 0.0 for r in held)
and min(r["b"] for r in tail_n) == 2000.0
and abs(mean(tail_n, "grid") - 1004) < 20)
check(f"{shape}: A's stale-reading gate trips at most once, the site is "
f"never stopped after the first 5 minutes, and SAFETY-42 does not "
f"report A as not delivering",
ntr["a"] <= 1
and all(r["reason"] != "not-running" for r in tail_n)
and not any(r["da"] for r in new))
# ==============================================================================
print("2. case b: house 1200 W, which B alone can carry, A cuts out")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, _, _ = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
imp = sum(max(0.0, r["grid"]) for r in tail_n) * TICK / H
print(f" {shape}: old grid {mean(tail_o, 'grid'):+.0f} W, new grid "
f"{mean(tail_n, 'grid'):+.0f} W, new import {imp:.0f} Wh")
check(f"{shape}: PRECONDITION: the old law leaves a standing import",
mean(tail_o, "grid") > 250)
check(f"{shape}: B takes over the whole house and the grid sits near its "
f"target", abs(mean(tail_n, "grid") + 10) < 25 and imp < 10)
pa = probes(new, "a")
in_probe = [r for r in new
if any(p <= r["t"] < p + PROBE_S for p in pa)]
check(f"{shape}: A is probed at 200 W every 15 minutes while B carries "
f"the rest, and the two never pull opposite ways",
len(pa) >= 3
and all(abs(q - p - PROBE_EVERY_S - PROBE_S) <= 2 * TICK
for p, q in zip(pa, pa[1:]))
and all(r["b"] > 0 for r in in_probe)
and all(r["a"] * r["b"] >= 0 for r in new))
# ==============================================================================
print("3. case c: both cut out; the site holds 0 W, probes one at a time")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit)
old, _, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit, old=True)
tail_n, tail_o = after(new, 600), after(old, 600)
pa, pb = probes(new, "a"), probes(new, "b")
print(f" {shape}: old mean asks A/B {mean(tail_o, 'a'):.0f}/"
f"{mean(tail_o, 'b'):.0f} W; new {mean(tail_n, 'a'):.0f}/"
f"{mean(tail_n, 'b'):.0f} W, probes A {len(pa)} B {len(pb)}")
check(f"{shape}: PRECONDITION: the old law keeps asking two empty "
f"batteries", mean(tail_o, "a") > 150 and mean(tail_o, "b") > 150)
check(f"{shape}: the site holds 0 W: the loop says `battery refuses` or "
f"`battery probe` and each battery is asked 0 W or its probe",
all(r["reason"] in (control.REFUSED, control.PROBING)
for r in tail_n)
and all(r["a"] in (0.0, PROBE_W) and r["b"] in (0.0, PROBE_W)
for r in tail_n))
check(f"{shape}: both batteries are probed, never at the same time",
len(pa) >= 3 and len(pb) >= 3
and not any(r["a"] and r["b"] for r in new if r["t"] > 300))
# ==============================================================================
print("4. recovery: the reseed stops the parked site target reaching the grid")
# ==============================================================================
# Both cut, the house drops to 940 W at 1 h, A recovers at 1.5 h with no sign
# change. Phase A, with the envelope but no reseed and no site hold: -1059 W.
ld = (lambda t: 3000.0 if t < H else 940.0)
for shape, jit in SHAPES:
rows, _, c = run(ld, {"a": cut_at(120, 1.5 * H), "b": cut_at(150)},
2 * H, jitter=jit)
win = after(rows, 1.5 * H, 1.5 * H + 900)
worst = min(r["grid"] for r in win)
print(f" {shape}: worst grid after A recovers {worst:+.0f} W; A "
f"refused {c.inverters[0].refused}")
check(f"{shape}: A's recovery is found (at its next probe at the latest) "
f"and never exports more than 250 W",
c.inverters[0].refused == 0 and worst > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# Case a: B at its rating, the site target parked at 3600 W while the house
# still imports; the house falls to 2500 W and A recovers. Without the reseed A
# is handed the parked 1600 W at once, 1100 W more than the house needs.
ld2 = (lambda t: 3000.0 if t < 0.5 * H else 2500.0)
rows, _, c = run(ld2, {"a": cut_at(120, 0.75 * H), "b": never}, 1.5 * H)
win = after(rows, 0.75 * H, 0.75 * H + 900)
print(f" a: worst grid when A recovers {min(r['grid'] for r in win):+.0f} W")
check("case a: a recovery hands A no parked site target (worst export under "
"250 W)", min(r["grid"] for r in win) > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# ==============================================================================
print("5. exits, words and the circulation guard")
# ==============================================================================
# Solar: the house exports from 30 min, so the loop wants to charge.
rows, _, c = run(lambda t: 3000.0 if t < 0.5 * H else -2500.0,
{"a": cut_at(120), "b": never}, H)
check("the grid asking the other direction lets A charge at once (its limit "
"closes only the refused direction) and a minute of charge ends the "
"refusal",
c.inverters[0].refused == 0 and min(r["a"] for r in after(rows, 0.6 * H))
< -500 and any("the other way" in e for e in c.events))
def _stop(c):
c.auto = False
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 0.5 * H,
events={1200.0: _stop})
check("stopping control ends the refusal",
c.inverters[0].refused == 0
and any("control stopped" in e for e in c.events))
# A small house: the site asks under 200 W, so a probe may not pin A (B would
# have to charge while A gives power). The probe waits instead.
rows, _, c = run(lambda t: 3000.0 if t < 600 else 120.0,
{"a": cut_at(120), "b": never}, 1.2 * H)
check("with the site asking under 200 W no probe is sent and no inverter is "
"ever asked the opposite way to the other",
c.inverters[0].refused == 1
and probes(after(rows, 700), "a") == []
and all(r["a"] * r["b"] >= 0 for r in rows)
and not any("ARBITER" in e for e in c.events))
# The hold binds every strategy's ask: a peak-guard SET at P_PEAK on the site
# is still placed around a refusing unit's closed limit.
_res = resolve([Claim.set("peak", P_PEAK, 3000.0, "quarter peak"),
Claim.set("loop", P_LOOP, 500.0, "tracking"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-a"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-b"),
Claim.limit("refused", P_SAFETY, hi=0.0,
reason=control.REFUSED, actuator="inverter-a")],
units=("inverter-a", "inverter-b"),
live=("inverter-a", "inverter-b"))
check("a P_SAFETY refusal limit binds a higher-priority strategy too: the "
"peak guard's 3000 W goes to B, A gets 0 W",
_res.shares.get("inverter-a") == 0 and _res.shares.get("inverter-b")
== 2000)
class _Grab(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append((record.levelno, record.getMessage()))
_g = _Grab()
_lg = logging.getLogger("goodwe")
_lv = _lg.level
_lg.setLevel(logging.INFO)
_lg.addHandler(_g)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
soc={"a": 10.0, "b": 50.0})
_lg.removeHandler(_g)
_lg.setLevel(_lv)
warns = [x for lv, x in _g.lines if lv == logging.WARNING
and "REFUSES_ABOVE_CUT_OUT" in x]
infos = [x for lv, x in _g.lines if lv == logging.INFO
and "BATTERY_REFUSES" in x]
print(f" {warns} / {infos}")
check("a refusal above the configured cut-out level logs INFO and one WARNING "
"naming the inverter, and a battery at its cut-out logs neither "
"(OPT-09's limit closes it first)",
len(warns) == 1 and warns[0].startswith("inverter-a ")
and len(infos) == 1 and infos[0].startswith("inverter-a:")
and len(warns[0].split()) <= 25)
# A night load of 400 W: each unit is asked about 400 W once the loop has
# made up A's missing half, under `saturation_w`. Engaging at PROBE_W is what
# refuses A here; at 500 W it would keep a phantom share that SAFETY-43 names.
rows, _, c = run(lambda t: 400.0, {"a": cut_at(0), "b": never}, 1200)
check("a 400 W night load: A is refused at a share under 500 W, B carries the "
"house and nothing reports A as not delivering",
c.inverters[0].refused == 1 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25
and mean(after(rows, 300), "b") > 380)
# A night load of 150 W: A's share never passes PROBE_W, so it is never
# refused, and SAFETY-43 must not name it as not delivering either: a battery
# asked under 200 W that gives under 100 W is a refusal too small to call.
rows, _, c = run(lambda t: 150.0, {"a": cut_at(0), "b": never}, 1200)
check("a small share on an empty battery is neither refused nor reported as "
"not delivering, and the grid stays near its target",
c.inverters[0].refused == 0 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25)
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
notes = c.liveness_notes()
check("the status page names the refusing inverter",
any(n.startswith("inverter-a: the battery refuses to give power")
for n in notes))
# Shadow mode writes nothing, so nothing is asked and nothing can refuse.
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 1200,
extra={"shadow_mode": True})
check("shadow mode: no inverter is refused and both stay at 0 W",
c.inverters[0].refused == 0 and c.inverters[1].refused == 0
and all(r["a"] == 0.0 and r["b"] == 0.0 for r in rows))
# Review. A 1 s meter makes 12 cycles 11 s, shorter than a healthy battery
# reading's lag (6 s dead time plus a 13 s publish). Ordinary load steps then
# refused both inverters and dropped the site to 0 W (16 times an hour). The
# evidence must also span REFUSED_SPAN_S.
_steps = (lambda t: 300.0 if int(t // 120) % 2 == 0 else 3000.0)
rows, _, c = run(_steps, {"a": never, "b": never}, H, tick=1.0,
lag=(6.0, 13.0))
check("a 1 s meter and slow battery readings: load steps for an hour refuse "
"neither healthy inverter",
not any(r["ra"] or r["rb"] for r in rows))
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
tick=1.0, lag=(6.0, 13.0))
_eng = next((r["t"] for r in rows if r["ra"]), None)
check("...and the same slow readings still refuse a battery that is cut "
"out, after the span and well inside the 185 s report",
_eng is not None and 120 + m.REFUSED_SPAN_S <= _eng < 185
and not any(r["da"] for r in rows))
# Review. A full battery that stops taking charge is refused (charge), and
# it is always above its cut-out level: that says nothing about the setting,
# so it gets the INFO line and no REFUSES_ABOVE_CUT_OUT warning.
_g2 = _Grab()
_lg.setLevel(logging.INFO)
_lg.addHandler(_g2)
rows, _, c = run(lambda t: -3000.0, {"a": never, "b": never}, 600,
soc={"a": 99.0, "b": 60.0}, full={"a": lambda t: t >= 120})
_lg.removeHandler(_g2)
_lg.setLevel(_lv)
check("a full battery that stops taking charge is refused that way only, "
"with the INFO line and no warning about its cut-out level",
c.inverters[0].refused == -1
and any(lv == logging.INFO and "BATTERY_REFUSES" in x
for lv, x in _g2.lines)
and not any("REFUSES_ABOVE_CUT_OUT" in x for _lv2, x in _g2.lines))
# ==============================================================================
print("6. one inverter is untouched")
# ==============================================================================
def _boom(*a, **k):
raise AssertionError("SAFETY-73 code ran at N=1")
_saved = {k: getattr(Controller, k) for k in (
"_unit_refusals", "_unit_refusal_start", "_unit_refusal_end",
"_unit_probe", "_site_refused")}
for k in _saved:
setattr(Controller, k, _boom)
try:
import test_refusal as _t1
_one_ok = not _t1.fails
except (AssertionError, SystemExit) as e:
_one_ok = isinstance(e, SystemExit) and not e.code
finally:
for k, v in _saved.items():
setattr(Controller, k, v)
check("SAFETY-52's whole single-inverter suite passes with every SAFETY-73 "
"method made to raise, so none of it runs at N=1", _one_ok)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+464
View File
@@ -0,0 +1,464 @@
"""UX-13 P2: the Installer settings view. `python3 test_settings.py`
The view is drawn from app/uilayout.py through app/uipage.py and saves the
page model through app/uimodel.py and the shipped validator. Part 1 checks
that every group and every mapped field reaches the page with its words,
range and choices. Part 2 checks the page script names no option key. Parts
3-5 drive the real routes over HTTP against a fake Supervisor: the save round
trip, where a refusal lands, and a stale save. Part 6 holds the view's words
to STYLE. The headless-browser click-through is in the PR, not in CI (the CI
image has no browser).
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import copy
import glob
import io
import json
import logging
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) # the validator's own log lines
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from app import selfconfig, uilayout as L, uimodel as U, uipage, web # noqa: E402
from app.validate import validate_startup_config # noqa: E402
from named_absence import field # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
DEFAULTS = D.defaults(D.CONFIG)
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt")
TWO = dict(SITE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.a", "max_w": 2500,
"bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 2500,
"bms_soc_floor": 10}])
UI_TEXT = uipage.ui_text()
W = UI_TEXT["settings"]
def browser(value):
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, list):
return [browser(v) for v in value]
if isinstance(value, dict):
return {k: browser(v) for k, v in value.items()}
return value
# --------------------------------------------------------------------------- #
print("1. every group and every mapped field reaches the page")
# --------------------------------------------------------------------------- #
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
_flat = []
for _g in LAYOUT["groups"]:
for _p in _g["parts"]:
for _f in _p["fields"]:
_flat.append((_g["id"], _f))
_flat += [(_g["id"], _s) for _s in _f.get("fields", [])]
_keys = [f["key"] for _g, f in _flat]
FIELD = {f["key"]: f for _g, f in _flat}
check("the six groups, in the design's order",
[g["id"] for g in LAYOUT["groups"]]
== ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"]
and [g["name"] for g in LAYOUT["groups"]]
== ["Meter", "Inverters and batteries", "Tariff and prices",
"Safety limits", "Maintenance", "Control tuning"])
check(f"every field of the table is on the page once ({len(_keys)} fields)",
sorted(_keys) == sorted(f.key for _g, _p, f in L.fields())
and len(_keys) == len(set(_keys)))
_counts = {g["id"]: sum(1 for gid, _f in _flat if gid == g["id"])
for g in LAYOUT["groups"]}
print(f" fields per group: {_counts}")
check("every field has a label, and every option field's label is its "
"translation without the group prefix",
all(f["label"] and " · " not in f["label"] for _g, f in _flat)
and field(FIELD, "meter_source")["label"]
== "Where the meter reading comes from")
_ranged = [(f["key"], f.get("min"), f.get("max")) for _g, f in _flat
if f["control"] in (L.SLIDER, L.LOCKED)]
check(f"every slider and locked value has its range from the schema "
f"({len(_ranged)})", all(lo is not None and hi is not None
for _k, lo, hi in _ranged)
and ("peak_cap_w", 500.0, 15000.0) in _ranged)
check("the peak limit is a kW slider over a value stored in W, and its label "
"does not say W (the unit is shown beside the control)",
field(FIELD, "peak_cap_w")["scale"] == 1000
and field(FIELD, "peak_cap_w")["label"] == "Peak limit"
and not [f["key"] for _g, f in _flat
if f["unit"] and re.search(r"\([^()\s]{1,6}\)", f["label"])])
_choice = [f for _g, f in _flat
if f["control"] in (L.SEGMENTED, L.SELECT, L.CHOICE)]
check("every choice control has choices with words",
all(f["choices"] and all(c[1] for c in f["choices"]) for f in _choice))
_ms = field(FIELD, "meter_source")
check("the meter source is radio cards with a line of help on each",
_ms["control"] == L.CHOICE and set(_ms["hints"]) == {c[0] for c in
_ms["choices"]})
_reg = field(FIELD, "ui.region")
check("the region shows its preset year beside the choice (P0 review)",
["flanders", "Flanders, Fluvius Antwerpen, 2026 figures", False]
in _reg["choices"] and _reg["presets"]["flanders"]["year"] == 2026)
check("...and says plainly that Wallonia and Brussels have no preset yet "
"(TARIFF-22)", _reg["later"] == [["wallonia", "Wallonia, no preset yet"],
["brussels", "Brussels, no preset yet"]]
and "Wallonia or Brussels" in _reg["help"])
_locked = sorted(f["key"] for _g, f in _flat if f["control"] == L.LOCKED)
check(f"measured and derived values are locked behind 'Change' "
f"({len(_locked)}): every control tuning value, every safety stop and "
"the peak reserve",
{f.key for p in L.GROUPS[-1].parts for f in p.fields} <= set(_locked)
and "sign_disagree_w" in _locked and "peak_soc_floor" in _locked
and LAYOUT["groups"][-1]["support_only"])
check("each group says when its settings apply",
all(g["applies"] == [UI_TEXT["applies"]["restart"]["name"]]
for g in LAYOUT["groups"]))
_inv = field(FIELD, "inverters")
check("the inverter cards carry their count, and adding one makes a list",
_inv["count"] == U.COUNT and _inv["grow"] == U.GROWN
and [s["key"] for s in _inv["fields"]]
== [f.key for f in L.ROSTER_FIELDS])
check("a page-only field names the option keys it writes, for the grey key "
"line", _reg["writes"] == ["price_taxes_profile"]
and field(FIELD, "ui.solar")["writes"]
== ["pv_entities", "pv_entity", "pv_invert_entities"]
and "static_profile" in field(FIELD, "ui.contract")["writes"])
check("without a Supervisor schema the page still draws, with no ranges",
len(uipage.layout([], {})["groups"]) == 6)
# --------------------------------------------------------------------------- #
print("\n2. the page script names no option key")
# --------------------------------------------------------------------------- #
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
ORDER, _nested = TL.config_surface(D.CONFIG)
_leak = sorted(k for k in ORDER if re.search(
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
check(f"settings.js names no option key ({_leak})", not _leak)
check("data is set as text, never as HTML",
"innerHTML" not in JS and "insertAdjacentHTML" not in JS)
_fetch = sorted(set(re.findall(r'fetch\("([^"?]+)', JS)))
check(f"every request is relative, to the add-on's own routes ({_fetch})",
_fetch == ["config/model", "config/restart", "config/save",
"config/validate", "ha/entities"])
def _resolve(path, node=W):
for k in path.split("."):
if not isinstance(node, dict) or k not in node:
return None
node = node[k]
return node
_used = {re.sub(r"\.(replace|join|length)$", "", k) for k in
re.findall(r'\bW\.([a-z_]+(?:\.[a-z_]+)*)', JS)}
_used |= set(re.findall(r'data-s(?:-ph|-label)?="([\w.]+)"', io.open(
os.path.join(HERE, "app", "static", "index.html"), encoding="utf-8").read()))
_miss = sorted(k for k in _used if _resolve(k) is None)
check(f"every word the view asks for is in app/ui_text ({len(_used)} keys, "
f"missing: {_miss})", len(_used) > 25 and not _miss)
check("search matches a field's label, its key and its help",
"(f.label + \" \" + keys + \" \" + f.key + \" \" + (f.help || \"\"))"
in JS)
check("a locked value shows no input until 'Change' is pressed",
re.search(r"function locked\(.*?\n }\n", JS, re.S)
and "UNLOCKED.has(id)) { open(); return box; }" in JS)
check("nothing is saved while the add-on's own checks refuse the model",
"if (MSG.some((m) => m.tier === \"bad\")) { toast(" in JS)
check("Discard reloads the stored settings (after a stale save the page's "
"copy is no longer what is stored)",
re.search(r'"#sb-discard"\)\.addEventListener\("click", async \(\) => '
r'\{\s*await load\(\);', JS) is not None)
check("a load locks every locked value again, and a choice the page can only "
"keep stays offered while it is the stored value",
"UNLOCKED.clear();" in JS.split("async function load")[1].split("render();")[0]
and "same(c[0], was)" in JS)
check("removing cards back to one restores the stored shape (a flat inverter "
"is not turned into a list of one)",
"if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);"
in JS)
check("the old settings page stays reachable (until P5)",
'href="config" id="set-classic"' in web.page())
# --------------------------------------------------------------------------- #
# The real routes, over HTTP, against a fake Supervisor
# --------------------------------------------------------------------------- #
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
self.writes = []
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
self.writes.append(copy.deepcopy(options))
self.options = copy.deepcopy(options)
async def restart(self, confirm):
raise AssertionError("no restart here")
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return []
class Controller:
def __init__(self, opts):
self.o, self.hass, self.inverters = dict(opts), Hass(), []
def log_event(self, text, hint=None):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": []}
async def handle_action(self, what):
raise AssertionError(f"the settings view reached {what!r}")
async def session(stored, steps):
"""Run `steps(s, root)` against the app over a fake Supervisor."""
sup = Supervisor(stored)
runner = aioweb.AppRunner(web.build_app(Controller(stored), sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
out = await steps(s, root, sup)
finally:
await runner.cleanup()
return out, sup
async def _get(s, url):
async with s.get(url) as r:
return await r.json()
async def _post(s, url, body):
async with s.post(url, json=body) as r:
return r.status, await r.json()
# --------------------------------------------------------------------------- #
print("\n3. the save round trip")
# --------------------------------------------------------------------------- #
check("the fixture site starts (so a refusal below is the edit's)",
not validate_startup_config(TWO) and not validate_startup_config(SITE))
async def _round(s, root, sup):
out = {}
m = await _get(s, root + "config/model?lang=en")
out["model"] = m
model = browser(m["ui"])
out["same"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
out["after_same"] = copy.deepcopy(sup.options)
m = await _get(s, root + "config/model?lang=en")
model = browser(m["ui"])
model["peak_cap_w"] = 4200
model["inverters"][1]["max_w"] = 2200
out["edit"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
return out
_stored = dict(TWO, gain=0.5, target_grid_w=-10.0, peak_cap_w=3500)
_r, _sup = asyncio.run(session(_stored, _round))
check("config/model carries the layout and the page model",
_r["model"]["layout"] == json.loads(json.dumps(uipage.layout(
SCHEMA, TRANSLATIONS))) and _r["model"]["ui"]["inverter_count"] == 2)
check("a save that changed nothing writes an identical blob: same values, "
"types and key order, although the browser turned -10.0 into -10",
_r["same"][0] == 200 and json.dumps(_r["after_same"])
== json.dumps(_stored) and isinstance(_r["after_same"]["target_grid_w"],
float))
_written = _sup.writes[-1]
print(f" the edit: {_r['edit']}, changed "
f"{sorted(k for k in _stored if _written[k] != _stored[k])}")
check("an edit writes exactly the fields edited, the peak limit in W",
_r["edit"][0] == 200
and sorted(k for k in _stored if _written[k] != _stored[k])
== ["inverters", "peak_cap_w"] and _written["peak_cap_w"] == 4200
and _written["inverters"][1]["max_w"] == 2200
and _written["inverters"][0] == _stored["inverters"][0])
_blobs = [("config.yaml", DEFAULTS), ("site", SITE), ("two", TWO)] + [
(os.path.basename(f), json.load(open(f))) for f in sorted(glob.glob(
os.path.join(HERE, "scenarios", "ux13", "test23", "*.json")))]
_bad = [n for n, o in _blobs if json.dumps(uipage.to_options(browser(
json.loads(json.dumps(U.options_to_ui(o)))), o)) != json.dumps(o)]
check(f"every known blob comes back byte for byte through the browser "
f"({len(_blobs)} blobs, not: {_bad})", not _bad and len(_blobs) >= 8)
_grown = U.options_to_ui(SITE)
_grown["inverters"].append(dict(_grown["inverters"][0], name="b",
setpoint_entity="number.b"))
_grown["inverters"][0]["name"] = "a"
_grown.update(U.GROWN)
_g = U.ui_to_options(_grown)
check("'Add an inverter' on a flat site writes a list and clears the flat "
"power request entity, which the validator refuses beside a list",
[e["name"] for e in _g["inverters"]] == ["a", "b"]
and _g["setpoint_entity"] == "" and not validate_startup_config(_g))
# --------------------------------------------------------------------------- #
print("\n4. a refusal lands beside the field it is about")
# --------------------------------------------------------------------------- #
async def _refuse(s, root, sup):
out = {}
m = await _get(s, root + "config/model?lang=en")
base = m["ui"]
async def validate(**edits):
model = copy.deepcopy(base)
for k, v in edits.items():
if k == "card":
model["inverters"][v[0]][v[1]] = v[2]
else:
model[k.replace("__", ".")] = v
return (await _post(s, root + "config/validate", {"model": model}))[1]
out["card"] = await validate(card=(1, "setpoint_entity", ""))
out["same"] = await validate(card=(1, "setpoint_entity", "number.a"))
out["floor"] = await validate(peak_soc_floor=5)
out["derive"] = await validate(ui__contract="dynamic", price_provider="")
model = copy.deepcopy(base)
model.update({"ui.contract": "dynamic", "price_provider": ""})
out["derive_save"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"]})
out["derive_writes"] = len(sup.writes)
model = copy.deepcopy(base)
model["inverters"][1]["setpoint_entity"] = ""
out["save"] = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
return out
_r, _sup = asyncio.run(session(TWO, _refuse))
def _at(payload, tier="bad"):
return {(m["field"], m["card"]) for m in payload["messages"]
if m["tier"] == tier}
print(f" blank power request entity on card 2: {_r['card']['messages']}")
check("a blank power request entity on the second card is refused beside "
"that card's field, and not beside the list as well",
("inverters.setpoint_entity", 1) in _at(_r["card"])
and ("inverters", None) not in _at(_r["card"]))
print(f" two cards on one entity: {_r['same']['messages']}")
check("two cards on one entity are refused beside that field on both cards",
{("inverters.setpoint_entity", 0), ("inverters.setpoint_entity", 1)}
== _at(_r["same"]))
check("a peak reserve under the cut-out level lands on the peak reserve and "
"on each card's cut-out level",
{("peak_soc_floor", None), ("inverters.bms_soc_floor", 0),
("inverters.bms_soc_floor", 1)} <= _at(_r["floor"]))
check("a model no blob can say is refused beside its field, in plain words",
_r["derive"]["messages"] == [{
"field": "price_provider", "card": None, "tier": "bad",
"text": W["derive"]["no_source"]["description"]}])
check("a save no blob can say answers 422 with its sentence and writes nothing",
_r["derive_save"][0] == 422 and _r["derive_writes"] == 0
and _r["derive_save"][1]["messages"] == _r["derive"]["messages"])
check("a refused save answers 422 with the same placement and writes nothing",
_r["save"][0] == 422 and ("inverters.setpoint_entity", 1)
in _at(_r["save"][1]) and _sup.writes == [])
_fixed = U.options_to_ui(dict(TWO, static_profile="not json"))
check("a sentence about a key the page derives goes to the field that writes "
"it, or to the visible choice that sets it",
uipage.targets("static_profile", "", _fixed) == [("ui.contract", None)]
and uipage.targets("max_w", "", U.options_to_ui(SITE))
== [("inverters.max_w", 0)]
and uipage.targets("pv_entity", "", _fixed) == [("ui.solar", None)])
check("a sentence naming no setting on the page is kept, with no field",
uipage.anchors(["Something odd."], [], _fixed, ["gain"])
== [{"field": None, "card": None, "tier": "bad",
"text": "Something odd."}])
# --------------------------------------------------------------------------- #
print("\n5. a stale save names the fields that changed elsewhere")
# --------------------------------------------------------------------------- #
async def _stale(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000 # the Configuration tab saves
model = copy.deepcopy(m["ui"])
model["gain"] = 0.4
return await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
_r, _sup = asyncio.run(session(TWO, _stale))
check("409 with the page fields to look at, and nothing written",
_r[0] == 409 and _r[1]["kind"] == "stale"
and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == []
and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options))
# --------------------------------------------------------------------------- #
print("\n6. the view's words keep to STYLE")
# --------------------------------------------------------------------------- #
def _texts(node, path=""):
for k, v in node.items():
if isinstance(v, dict):
yield from _texts(v, f"{path}{k}.")
elif isinstance(v, str):
yield f"{path}{k}", v
_long = [(p, t) for p, t in _texts(W)
if len(t.split()) > (25 if p.endswith("description") else 6)
or (p.endswith("description") and len(re.findall(r"[.!?](?:\s|$)", t)) > 1)
or ";" in t]
check(f"every settings string is one sentence of at most 25 words, or a "
f"name of at most 6 ({_long})", not _long)
_labels = [c[1] for _g, f in _flat for c in f.get("choices", [])]
check("every choice, including the region with its year, is at most 6 words",
all(len(x.split()) <= 6 for x in _labels))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+7 -4
View File
@@ -82,8 +82,9 @@ check("every control, entity kind and show operator is a known one",
all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS)
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
for _g, _p2, f in ROWS))
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
check("every show condition reads an option, a page field or inverter_count",
_names = set(ORDER) | set(KEYS) | {uimodel.COUNT} | set(uimodel.GROWN)
check("every show condition reads an option, a page field, or the inverter "
"count or shape",
all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show))
check("every entity control has a kind, and no other control has one",
all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind)
@@ -189,11 +190,13 @@ _owned = set(L.DERIVED) | {"inverters", "price_provider",
_extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned)
check(f"uimodel.py names only the keys its derivations write (others: "
f"{_extra})", not _extra)
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py")
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py",
"uipage.py")
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
encoding="utf-8").read())
if k in ORDER})
check(f"haentities.py and web.py name no option key ({_leak})", not _leak)
check(f"haentities.py, web.py and uipage.py name no option key ({_leak})",
not _leak)
print()
if fails:
+259
View File
@@ -0,0 +1,259 @@
"""UX-13: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
STATE is running, shadow or alarm. The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
(no JS errors, tooltips, phone width, dark mode) described in the PR.
P2: the fixture Supervisor serves config.yaml's defaults with this site's
options, a schema read off config.yaml the way the Supervisor renders it
(`schema_ui()`) and translations/en.yaml, and keeps what the settings view
saves in memory, so the Installer view can be clicked through end to end.
"""
import asyncio
import io
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
from aiohttp import web as aioweb # noqa: E402
from app import homeview, web # noqa: E402
from app.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
import translations_lint as TL # noqa: E402
PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
class Store(dict):
def set(self, k, v):
self[k] = v
def set_time(self, k, v):
self[k] = v.isoformat()
def get_time(self, k):
v = self.get(k)
return None if v is None else datetime.fromisoformat(v)
@property
def data(self):
return self
class Pub:
enabled = False
def publish(self, _v):
pass
def close(self):
pass
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def set_number(self, _e, _v):
return True
async def call(self, *_a):
return True
async def states(self):
return STATES
def _state(eid, name, state, unit=None, cls=None):
return {"entity_id": eid, "state": state,
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
"device_class": cls}}
STATES = [
_state("sensor.grid", "Grid power", "-10", "W", "power"),
_state("sensor.grid_kw", "Grid power in kW", "-0.01", "kW", "power"),
_state("sensor.pv", "Solar power", "3900", "W", "power"),
_state("sensor.soc_a", "Battery A level", "62", "%", "battery"),
_state("sensor.soc_b", "Battery B level", "35", "%", "battery"),
_state("sensor.batt_a", "Battery A power", "-2500", "W", "power"),
_state("sensor.batt_b", "Battery B power", "-300", "W", "power"),
_state("number.a", "Battery A power request", "0", "W"),
_state("number.b", "Battery B power request", "0", "W"),
_state("sensor.peak_forecast", "Quarter-hour peak forecast", "1240", "W",
"power"),
]
# --- config.yaml as the Supervisor renders it (App.schema_ui) --------------- #
def _node(name, spec):
spec = spec.strip()
optional = spec.endswith("?")
spec = spec.rstrip("?")
out = {"name": name, "required": not optional, "optional": optional}
m = re.match(r"(int|float)\(([^,]*),([^)]*)\)$", spec)
if m:
out.update(type="integer" if m.group(1) == "int" else "float",
lengthMin=float(m.group(2)), lengthMax=float(m.group(3)))
elif spec.startswith("list("):
out.update(type="select", options=spec[5:-1].split("|"))
elif spec in ("bool",):
out["type"] = "boolean"
elif spec in ("int", "float"):
out["type"] = "integer" if spec == "int" else "float"
else:
out["type"] = "string"
return out
def schema_ui(text):
"""config.yaml's `schema:` block in the Supervisor's schema_ui shape: the
fixture for tests and this demo, which cannot run the Supervisor."""
block = text[text.index("\nschema:"):].splitlines()[1:]
out, i = [], 0
while i < len(block):
line = block[i]
m = re.match(r"^ ([a-z_0-9]+):\s*(\S.*?)?\s*$", line)
i += 1
if not m:
continue
name, spec = m.group(1), m.group(2)
if spec:
out.append(_node(name, spec))
continue
items = []
while i < len(block) and (block[i].startswith(" ") or not block[i].strip()):
if block[i].strip() and not block[i].strip().startswith("#"):
items.append(block[i].strip())
i += 1
if items and items[0].startswith("- ") and ":" in items[0]:
fields = [items[0][2:]] + items[1:]
out.append({"name": name, "type": "schema", "optional": True,
"multiple": True,
"schema": [_node(*f.split(":", 1)) for f in fields]})
else:
node = _node(name, items[0][2:] if items else "str")
node.update(multiple=True, required=False, optional=True)
out.append(node)
return out
def defaults(text):
"""config.yaml's `options:` block (as test_uimodel.py reads it)."""
block = text[text.index("\noptions:"):text.index("\nschema:")]
out = {}
for key, raw in re.findall(r"^ ([a-z_0-9]+):(.*)$", block, re.M):
raw = raw.split(" #")[0].strip()
try:
out[key] = json.loads(raw)
except ValueError:
out[key] = raw
return out
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
class Supervisor:
"""The stored options, kept in memory: a save here writes nothing else."""
def __init__(self, options):
self.options = options
self.writes = 0
async def info(self):
return {"options": json.loads(json.dumps(self.options)),
"schema": schema_ui(CONFIG),
"translations": {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}}
async def write_options(self, options):
self.options = json.loads(json.dumps(options))
self.writes += 1
print(f"options saved ({self.writes})", flush=True)
async def restart(self, confirm):
print(f"restart asked ({confirm!r})", flush=True)
OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
"pv_entity": "sensor.pv", "maintenance_enabled": True,
"maintenance_start_hour": 18, "arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.05,
"inverters": [
{"name": "Battery A", "setpoint_entity": "number.a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 2500, "capacity_kwh": 9.6},
{"name": "Battery B", "setpoint_entity": "number.b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": 2500, "capacity_kwh": 5.4}]}
def controller(state):
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
c.auto = True
now = datetime.now(timezone.utc)
day0, _day1 = homeview.local_day(now)
pts = tuple(PricePoint(day0 + timedelta(days=d, hours=k), p, False)
for d in range(2) for k, p in enumerate(PRICES))
c.tariff_plan = TariffPlan(PriceRead.series(
pts, PriceProduct.DAY_AHEAD, "generic_dayahead", now), None,
"provider")
c.tariff_at = now
c.peak_guard = PeakDecision(engaged=False, floor_w=None,
ceiling_w=3500.0, ceiling_from="cap",
projected_w=1240.0)
for inv, soc, batt in zip(c.inverters, (62.0, 35.0), (-2500.0, -300.0)):
inv.soc, inv.batt = soc, (0.0 if state == "shadow" else batt)
inv.dev_min, inv.dev_max = -5000.0, 5000.0
if state != "running": # 0 W accepted: Home says "Held"
inv.last_write = 0.0
c.grid = -10.0 if state != "shadow" else -2810.0
c.pv_w = 3900.0
if state == "alarm":
c.inverters[0].sign_wd.tripped = True
return c
async def main(state, port, host="127.0.0.1"):
c = controller(state)
stored = dict(defaults(CONFIG), **OPTS)
stored.update(setpoint_entity="", meter_source="ha_signed",
p1_net_entity="sensor.grid", price_provider="generic_dayahead",
peak_forecast_entity="sensor.peak_forecast",
shadow_mode=c.o.get("shadow_mode", "off"),
inverters=[dict(i, bms_soc_floor=10)
for i in OPTS["inverters"]])
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], int(sys.argv[2]), *sys.argv[3:4]))