Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e6c260fe | ||
|
|
30cba1e58d | ||
|
|
63e7e8a133 | ||
|
|
159d7461d8 | ||
|
|
4de96752ff | ||
|
|
3198f346fd | ||
|
|
be57b87c34 | ||
|
|
515c11b380 | ||
|
|
7be09197d4 | ||
|
|
1752adf6e6 | ||
|
|
0a28f71872 | ||
|
|
c9ab48d6fc | ||
|
|
28a49ccf75 | ||
|
|
06aa09e28c | ||
|
|
1a9c7523c9 | ||
|
|
a36dca60dd | ||
|
|
fad73d4012 | ||
|
|
5a8d832eba | ||
|
|
f8d63cc614 | ||
|
|
016ed5a5a3 | ||
|
|
afd6af068d | ||
|
|
badea3cb2d | ||
|
|
15627c38a6 | ||
|
|
652097abde | ||
|
|
3c0f2b9669 | ||
|
|
1d33265aac | ||
|
|
1f97c7652f | ||
|
|
d1f7e3336e | ||
|
|
3c298af3d8 | ||
|
|
c628099e69 | ||
|
|
29837dfaa7 | ||
|
|
c4032ebc21 |
@@ -7,7 +7,8 @@ This applies to everything a home owner or installer reads: notifications, phone
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its reason code (`OUTPUT_CLAMP`, `STALE_READING`), because that is the evidence an installer searches for. The reason itself has 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 reason with the real code and checks it. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
|
||||
- **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.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||
questions answered yes.**
|
||||
|
||||
## The defect
|
||||
|
||||
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||
inside the interval a limit can produce. The request stood at +5000 W for
|
||||
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||
the request moved after the reading went flat, the stale-reading gate won
|
||||
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||
`saturation_w`. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||
|
||||
Other exits: the loop wants the other direction (charge is accepted at the
|
||||
floor), control stopped or started, another writer on the wire (the arbiter
|
||||
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||
what seeds it.
|
||||
|
||||
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||
`test_control.py` still holds).
|
||||
|
||||
## Evidence
|
||||
|
||||
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||
`replay_recorded_loop.py`, and should be read for deltas:
|
||||
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||
just above its floor, the probe is answered, control ramps, the pack empties
|
||||
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||
follow an other-direction exit the same way. On a site this is one
|
||||
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Review changes (#141)
|
||||
|
||||
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||
- **Any delivery ends the hold, not only an answered probe.** With
|
||||
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||
`test_refusal.py` part 5b).
|
||||
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||
battery only when its cut-out level is configured right.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||
|
||||
## What SAFETY-58 and SAFETY-59 must take into account
|
||||
|
||||
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||
compute-side engagement (12 cycles) to win, which it does only while the
|
||||
request stands still. Keep `test_refusal.py` part 4 green.
|
||||
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||
change a refusing battery no longer produces the pattern at one inverter. The
|
||||
DOCS text should say so, and that the `battery refuses` reason is the
|
||||
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||
`test_refusal.py` part 2 checks the inequality.
|
||||
@@ -0,0 +1,104 @@
|
||||
# SAFETY-73 - a battery that refuses, with two or more inverters
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
|
||||
new questions answered yes. SAFETY-52's four decisions carry over.**
|
||||
|
||||
## The defect
|
||||
|
||||
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
|
||||
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
|
||||
its state of charge reads above its configured cut-out, so the allocator kept
|
||||
handing it its weighted half. Measured on the real `Controller`, two units,
|
||||
house 3000 W, A cut out:
|
||||
|
||||
| register | law | B gives | grid | A's gate trips / h |
|
||||
|---|---|---|---|---|
|
||||
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
|
||||
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
|
||||
| jitter | old | 1910 W | +1094 W | 0 |
|
||||
| jitter | new | 2000 W | +1004 W | 0 |
|
||||
|
||||
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
|
||||
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
|
||||
(+1910 W each on the jitter shape), new holds the site at 0 W.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
||||
claim, source `refused`. It engages when the unit gives under
|
||||
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
|
||||
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
|
||||
(200 W), or when its stale-reading gate
|
||||
trips on a held reading under 100 W. The allocator then hands its share to
|
||||
the others within their own limits (owner decision 1). It is a hard unit
|
||||
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
||||
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
||||
arbitrage and maintenance are placed around it like the loop.
|
||||
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
|
||||
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
||||
unit: its share is 0 W, and `account()` treats it as parked.
|
||||
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
||||
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
|
||||
time.
|
||||
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
|
||||
others carry the rest. The pin stands only while the resolved site target
|
||||
asks at least 200 W its way; otherwise it would make a sibling take the
|
||||
opposite direction, so it falls back to the closed limit for that cycle.
|
||||
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
|
||||
100 W or more taken the other way for 12 cycles (charge at the floor; one
|
||||
cycle of the loop overshooting through zero is not enough); stop or start.
|
||||
On every exit the site target and integrator restart from the watts on the
|
||||
wire, or the reopened unit is handed a parked remainder at once.
|
||||
|
||||
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
|
||||
night load split two ways asks 200 W of each. With 500 W the empty battery
|
||||
kept a phantom share that SAFETY-43's relative arm then reported as not
|
||||
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
|
||||
under 100 W are "too small to call" in `account()`: the sibling covers the
|
||||
meter and nothing is asked of the empty battery that matters.
|
||||
|
||||
**Why one direction throughout.** On TEST-19's flapping family a command that
|
||||
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
|
||||
no-fault runs without the rule, 0 with it.
|
||||
|
||||
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
|
||||
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
|
||||
SAFETY-73 method made to raise.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `test_refusal_units.py`: cases a, b and c on both register shapes against
|
||||
the old behaviour, probes and their spacing, one-at-a-time probing, the
|
||||
recovery reseed, each exit, the small-ask and small-site-target cases, the
|
||||
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
|
||||
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
|
||||
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
|
||||
unit executing its share) graded with the engage clause: 0 engagements, the
|
||||
longest qualifying streak 5 cycles against 12. The grader is
|
||||
`goodwe-session/scratch_s73/s73_nofault.py`.
|
||||
- **Recovery.** Case a, A recovering into a house that fell below the parked
|
||||
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
||||
reseed and the site hold.
|
||||
|
||||
## Review changes
|
||||
|
||||
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
|
||||
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
|
||||
10-17 s publish). The reviewer's harness (real `Controller`, both units with
|
||||
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
|
||||
refused both healthy units 10-16 times an hour, and each time the site held
|
||||
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
|
||||
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
|
||||
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
|
||||
- **The cut-out WARNING is for a refusal to give power only.** A full battery
|
||||
that stops taking charge is always above its cut-out level, and the WARNING
|
||||
told the installer to check a setting that has nothing to do with it.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
||||
N=1. SAFETY-42/43 still report it.
|
||||
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
|
||||
refusing battery counted. The shortfall shows as `unmet_w`.
|
||||
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
|
||||
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
|
||||
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
|
||||
the reason it stays, and the reason the panel refuses to write a blob the
|
||||
validator rejected: this surface must never be the thing that removes itself.
|
||||
|
||||
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
|
||||
keeps the panel: options that name no inverter at all and have no other
|
||||
refusal, which is what a new install ships. The add-on then serves the page in
|
||||
setup-only mode and commands nothing. Every other refusal still exits 78.
|
||||
|
||||
**The panel shows fewer refusals than the startup gate can, never more.** Two
|
||||
of `validate.py`'s rules compare a configured ceiling against the setpoint
|
||||
device's *own* reported range, which is only knowable by asking Home Assistant.
|
||||
|
||||
@@ -78,3 +78,15 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line
|
||||
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
|
||||
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
|
||||
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
|
||||
# UX-11: safety log lines give the code, the figures go to DEBUG
|
||||
|
||||
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
|
||||
|
||||
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
|
||||
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
|
||||
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
|
||||
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
|
||||
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
|
||||
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
|
||||
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# UX-13 — one layout table, and why UI code may now name an option key
|
||||
|
||||
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
|
||||
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
|
||||
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
|
||||
|
||||
## 1. What UX-02 said, and why it no longer fits
|
||||
|
||||
UX-02 built the configuration panel from the Supervisor's schema, so
|
||||
`app/configui.py` contains "no option key, no label and no rule", and
|
||||
test_selfconfig.py part 5 checks that the page script names no key. That kept
|
||||
a second copy of the option surface from drifting, and it is why the panel is
|
||||
a flat list of fields in schema order.
|
||||
|
||||
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
|
||||
for a value stored in W, a picker for an entity, a field shown only for one
|
||||
meter source, locked measured values, a Simple view and a setup wizard. It
|
||||
also needs page fields that are not options at all: one inverter card that is
|
||||
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
|
||||
a contract and two prices that become `static_profile` JSON, and a region
|
||||
that becomes the levies figure. None of that can be read off the schema, so
|
||||
some code has to name option keys.
|
||||
|
||||
## 2. The decision
|
||||
|
||||
Option keys may appear in UI code in exactly two files:
|
||||
|
||||
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
|
||||
control, unit, display scale, step, entity kind, when it is shown, whether it
|
||||
is on the Simple view or a wizard step, and whether it applies now or after a
|
||||
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
|
||||
exactly once.
|
||||
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
|
||||
`ui_to_options()`. It may name only the keys it writes.
|
||||
|
||||
What UX-02 made authoritative stays authoritative:
|
||||
|
||||
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
|
||||
none. A unit holds no digit.
|
||||
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
|
||||
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
|
||||
test_messages.py.
|
||||
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
|
||||
"warn" or "info") for the page. A note never blocks a save, and passing the
|
||||
notes list does not change the refusals returned.
|
||||
- **The stored blob** is still the one truth. The page model carries the stored
|
||||
values of every derived key (`keep`). A derivation whose page fields are
|
||||
unchanged writes those values back untouched, so a blob the page cannot
|
||||
express survives a save of another field.
|
||||
|
||||
## 3. How it is held
|
||||
|
||||
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
|
||||
field, that the table does not place. It also checks that uimodel.py names
|
||||
only the keys it writes and that web.py and haentities.py name none.
|
||||
- test_uimodel.py round-trips options → page model → JSON → options for every
|
||||
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
|
||||
option files and config.yaml's defaults. All must be identical.
|
||||
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
|
||||
new validator. At P0: 261 calls, 158 refused, no refusal changed.
|
||||
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
|
||||
into `app/static/` and the same check applies there.
|
||||
- P2: the Installer view (`app/static/settings.js`) draws from
|
||||
`app/uipage.py`, which serialises the table and names no key itself
|
||||
(test_uilayout part 5, test_settings part 2). The view posts its page model
|
||||
to the existing `/config/validate` and `/config/save`, which turn it into
|
||||
options with `ui_to_options()` before the shipped validator runs. No route
|
||||
was added.
|
||||
- P4: the setup wizard draws the same model by each field's `wizard` step.
|
||||
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
|
||||
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
|
||||
names no key. See [[UX-13-setup-wizard]].
|
||||
|
||||
## 4. What this costs
|
||||
|
||||
The table is a second place that knows the option surface, which UX-02 set
|
||||
out to avoid. The cost is bounded by the coverage test: an option added to
|
||||
config.yaml and not placed here fails CI, rather than silently missing from
|
||||
the page. Changing a key's type or range needs no change here.
|
||||
@@ -0,0 +1,101 @@
|
||||
# UX-13 P4 — the setup wizard, and when it opens by itself
|
||||
|
||||
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
|
||||
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
|
||||
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
|
||||
|
||||
## 1. What the wizard is
|
||||
|
||||
The Setup tab draws the Installer view's page model one step at a time: a
|
||||
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
|
||||
controls, `/config/validate` and `/config/save`. No route was added.
|
||||
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
|
||||
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
|
||||
yet (OPT-11); it says they come later.
|
||||
|
||||
A step moves on when no refusal is anchored to one of its shown fields
|
||||
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
|
||||
no field at all blocks the last step, which lists every refusal.
|
||||
|
||||
## 2. When it opens by itself (`uipage.setup_needed`)
|
||||
|
||||
The page opens `#setup` only when it is opened with no route, and only when
|
||||
`/config/model` gives a reason:
|
||||
|
||||
- **`no_power_request`**: the stored options name no inverter to control.
|
||||
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
|
||||
setup-only mode (UX-15) writes the Store, so a new install's first good boot
|
||||
is fresh.
|
||||
|
||||
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
||||
R1). So a Store read from a file is an old site, and it is never sent to the
|
||||
wizard. A new install that restarts before the page is opened is not sent
|
||||
either, and its Setup tab still works. A site that runs control, or ever
|
||||
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
|
||||
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||
not.
|
||||
|
||||
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
|
||||
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
|
||||
naming no inverter is the gate's only refusal, the add-on serves the page in
|
||||
setup-only mode, which commands nothing, and the wizard opens with
|
||||
`no_power_request`. Setup-only mode writes no Store file, so the first-run
|
||||
rule above still holds for an install set up on the Configuration tab.
|
||||
|
||||
## 3. The kettle test and the sign checks
|
||||
|
||||
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
|
||||
`hass.state()` read, in W) before and after a kettle is switched on. The
|
||||
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
|
||||
off, a fall turns it on. For a net entity or import and export, a fall cannot
|
||||
be fixed by an option, so the step is not passed and the page says what to
|
||||
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
|
||||
the page, so the test is skipped and Home shows the direction after the
|
||||
restart. The installer may skip the test, because a cloud can fake a fall;
|
||||
the last step then warns, and after a fall it says the reading went down.
|
||||
|
||||
The battery and solar signs cannot be forced from the page, which never
|
||||
commands an inverter. The last step reports each battery's reading with its
|
||||
direction ("reads 800 W charging now, so check the inverter shows the same")
|
||||
and warns when a solar entity reads below zero without its "reads negative"
|
||||
switch. It also warns when meter + solar + battery, all known, add up to a
|
||||
house using less than -300 W: a battery sensor reversed while giving power.
|
||||
An idle or charging battery cannot be judged that way.
|
||||
|
||||
## 4. The last step
|
||||
|
||||
`validate.setup_checks()` sits beside the rules because it names option keys.
|
||||
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
|
||||
5 s old, the running options, the running meter reading and today's price
|
||||
count. Tiers are ok, info, warn and bad. A power request entity missing from
|
||||
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
|
||||
and only the entity list can tell. So is Home Assistant not answering, since
|
||||
the list is then unknown. The wizard does not save while anything is
|
||||
bad. It then saves through P2's path (`setup: "done"`) and asks for the
|
||||
restart as the second, confirmed request, with a dialog that names the 0 W.
|
||||
The shadow switch is on by default and writes `shadow_mode: on`, which holds
|
||||
every inverter at 0 W after a restart even if control is started. Off keeps the
|
||||
stored value, or `auto` if that was `on`. The switch goes only on the model the
|
||||
wizard posts, never into the page model the Installer view also saves.
|
||||
|
||||
## 5. The stale save lists only what moved (P2 test, finding a)
|
||||
|
||||
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
|
||||
stale save the add-on compares that blob with what is stored now, so the banner
|
||||
lists only the other surface's changes. `changed` (stored against the posted
|
||||
blob) is unchanged for the classic page's "theirs → mine" list. When the
|
||||
fingerprint was not handed out by this process, it falls back to `changed`.
|
||||
|
||||
## 6. How it is held
|
||||
|
||||
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
|
||||
it), step blocking, the first-run rule on new and TEST-23 sites, the live
|
||||
checks, the save through the P2 path, the stale fix, and the words.
|
||||
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
|
||||
install and three TEST-23 sites (88 accepted by the shipped validator).
|
||||
UX-15 added the shipped defaults as a start (setup-only mode). At P4
|
||||
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
|
||||
keys each.
|
||||
@@ -0,0 +1,114 @@
|
||||
# UX-15 — setup-only mode: a new install serves the wizard
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
|
||||
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
|
||||
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
|
||||
|
||||
## 1. The rule
|
||||
|
||||
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
|
||||
refused by rule 1 of `validate_startup_config()`: no inverter to control.
|
||||
Until now that was exit 78 before any page was served, and the installer had
|
||||
to fill in the entity on the Configuration tab before the wizard could open.
|
||||
|
||||
The add-on now starts in **setup-only mode** when, and only when
|
||||
(`setuponly.applies()`):
|
||||
|
||||
- the options name no inverter at all: no flat `setpoint_entity` and no
|
||||
`inverters` entry, and
|
||||
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
|
||||
refusal.
|
||||
|
||||
In setup-only mode it serves the page, says it is in setup (banner, `/status`
|
||||
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
|
||||
opened with no route (`setup_needed` is `no_power_request`). After the
|
||||
wizard's save and restart the normal gate applies to the saved options.
|
||||
|
||||
## 2. What still exits 78, and why
|
||||
|
||||
Everything else the gate refuses, including:
|
||||
|
||||
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
|
||||
`max_w`;
|
||||
- no power request entity **and** any other refusal;
|
||||
- an `inverters` list whose entry has a blank power request entity.
|
||||
|
||||
These are configurations somebody edited. The Configuration tab stored them,
|
||||
the log's refusal banner names each problem, and the tab is where they are
|
||||
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
|
||||
front of someone who needs to change one field. A narrow rule also keeps the
|
||||
setup-only decision to one comparison against one known sentence, so a new
|
||||
validator rule cannot widen it by accident: any other refusal fails the
|
||||
equality and exits 78.
|
||||
|
||||
The cost: an installer who first sets, say, a DSMR meter with no entities on
|
||||
the Configuration tab and leaves the power request entity empty gets exit 78,
|
||||
not the wizard. The log then names both fields.
|
||||
|
||||
## 3. Why it cannot command
|
||||
|
||||
Setup-only mode builds nothing that commands. `amain()` branches to
|
||||
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
|
||||
the tariff task are built, and it returns from there when the Supervisor
|
||||
stops it. What serves the page is `SetupOnly`, which holds no inverter.
|
||||
|
||||
Three checks make it structural rather than a convention:
|
||||
|
||||
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
|
||||
`call()` refuses before it sends anything, and every service call goes
|
||||
through it: `number.set_value` (`set_number()`), notifications, all of
|
||||
them. The flag is fixed at construction, and there is no setter.
|
||||
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
|
||||
refuses one that cannot.** So neither can be built on the wrong one.
|
||||
3. **`/action` answers 409** in setup-only mode, before
|
||||
`handle_action()`. Start, Stop and the battery cycle do nothing.
|
||||
|
||||
No MQTT client is created, so nothing is published. The page's other routes
|
||||
already read only, or write the add-on's own options (UX-02 §6).
|
||||
|
||||
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
|
||||
as in any mode. So a new install that is set up on the Configuration tab
|
||||
instead still has a fresh Store on its first normal boot, and the first-run
|
||||
rule (UX-13 §2) sends it to the wizard as before.
|
||||
|
||||
## 4. How it is held
|
||||
|
||||
- `test_setup_only.py` part 1: when it applies, including the three exit-78
|
||||
cases above. Part 2: the read-only write path refuses with nothing sent,
|
||||
and the two constructors refuse. Part 3 boots the real `main()` on the
|
||||
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
|
||||
logs every request: the page is served, `/status` says setup-only, Start
|
||||
and the cycle get 409, and there are 0 service calls and no MQTT request,
|
||||
also with `auto_start` on and a Store that says control was started. The
|
||||
wizard's save is then booted: it passes the normal gate, does not open the
|
||||
wizard again, and every write is 0 W (shadow). Part 4: the refused
|
||||
configurations exit 78 and serve nothing.
|
||||
- Mutations, each red: `applies()` always false or always true, the
|
||||
read-only refusal removed, the Controller's check removed, `amain()` giving
|
||||
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
|
||||
`return` after `serve()` removed.
|
||||
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
|
||||
defaults and an empty `/data` made 0 service calls and no MQTT request in
|
||||
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
|
||||
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
|
||||
and after the restart it ran in shadow: 11 of 11 writes were
|
||||
`set_value 0.0`.
|
||||
|
||||
## 5. The wizard fixes in the same card
|
||||
|
||||
- **F1.** `/config/validate` converts what it can of a page model
|
||||
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
|
||||
step beside its field and the live checks still run on the rest. A refusal
|
||||
about a key the failed conversion writes is left out, because it would
|
||||
judge the stored value. The save still refuses such a model (422, with
|
||||
`steps`).
|
||||
- **F2.** A save from the page model sends `base`, the model it opened. The
|
||||
add-on writes only the keys whose value differs from `base`, onto the blob
|
||||
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
|
||||
409 keeps the other surface's changes. A key both surfaces changed takes
|
||||
this page's value, which is what the second press after the banner means.
|
||||
- **Meter entity.** On the last step a meter entity Home Assistant does not
|
||||
have is red, like a missing power request entity, so a skipped kettle test
|
||||
cannot let it through. In the Installer view it stays a warning.
|
||||
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
|
||||
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
|
||||
@@ -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.
|
||||
@@ -526,7 +526,7 @@ accumulator is doing:
|
||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||
| `Loop reason` | — | the law's own word for this cycle |
|
||||
|
||||
`Loop reason` takes one of eight values:
|
||||
`Loop reason` takes one of ten values:
|
||||
|
||||
| value | what happened |
|
||||
|---|---|
|
||||
@@ -538,6 +538,35 @@ accumulator is doing:
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
|
||||
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||
whatever it is asked. The add-on then asks 0 W instead of repeating its last
|
||||
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
|
||||
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||
is not refusing and keeps its request.
|
||||
|
||||
**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 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
|
||||
@@ -3095,8 +3124,11 @@ stop control on the add-on page.
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||
replaces the card. The full figures and reason codes are on the ERROR or
|
||||
WARNING line of the add-on log.
|
||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||
reason code. The full figures are in `events` in the `status` JSON, also for a
|
||||
stop restored after a restart, and on the DEBUG line logged right after it.
|
||||
That DEBUG line only shows with “Log level” (log_level) set to `debug`; the
|
||||
default `info` hides it.
|
||||
|
||||
| Title | Severity | id | Card |
|
||||
|---|---|---|---|
|
||||
@@ -3189,6 +3221,47 @@ action log (`Controller.events`). The page no longer shows that log, but the
|
||||
page and add `status` to its address. The action-log tables in this document
|
||||
refer to it.*
|
||||
|
||||
### Setup, for a new install
|
||||
|
||||
The **Setup** tab walks an installer through six steps: the meter, the
|
||||
inverters and batteries, solar (optional), prices, the peak limit, and a last
|
||||
check. Each step shows the same fields as Settings, and nothing is saved until
|
||||
the last step.
|
||||
|
||||
- **The kettle test** on the meter step reads the meter, then reads it again
|
||||
after you switch on a kettle. If the reading goes down, the add-on flips its
|
||||
sign for you; with a net or import/export meter it tells you what to change.
|
||||
- **The last step** runs the add-on's own start checks and reads Home
|
||||
Assistant: the meter reading, that each power request entity exists, the
|
||||
battery levels, and whether prices are known. A missing power request or
|
||||
meter entity is red, so it cannot be saved.
|
||||
- **Save and restart** saves, then restarts the add-on. Every inverter is set
|
||||
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
|
||||
stays at 0 W until you turn shadow mode off under Settings.
|
||||
|
||||
Setup opens by itself on a new install's first start, until Setup finishes or
|
||||
control is started, and whenever the stored settings name no inverter to
|
||||
control. A site that already ran before is never sent there.
|
||||
|
||||
A new install names no inverter yet, so the add-on starts **in setup**: it
|
||||
serves the page and sends nothing to any inverter. Home says "Setup: nothing
|
||||
is controlled yet", and there is no Start button. The installer's steps:
|
||||
|
||||
1. Install the add-on, turn on **Watchdog** on its Info tab, and start it.
|
||||
Leave the Configuration tab as it is.
|
||||
2. Open the add-on's page. Setup opens by itself.
|
||||
3. Go through the six steps and press **Save and restart**. With "Start in
|
||||
shadow mode" on, every inverter stays at 0 W after the restart.
|
||||
4. On Home, check the meter direction and what the add-on would do. Turn
|
||||
shadow mode off under Settings when it looks right.
|
||||
|
||||
Any other problem in the stored settings still stops the add-on (exit code 78),
|
||||
and the log names each one. Fix those on the Configuration tab.
|
||||
|
||||
*For installers: cards UX-13 and UX-15, `DECISIONS/UX-13-setup-wizard.md` and
|
||||
`DECISIONS/UX-15-setup-only-mode.md`. In setup the `status` JSON has
|
||||
`setup_only: true`, and no MQTT entity is published.*
|
||||
|
||||
## Status page
|
||||
|
||||
The add-on page shows a banner at the top and a **Commissioning** list below
|
||||
@@ -3202,6 +3275,10 @@ inverter stays at 0 W. If the banner is red and says a safety stop stays on,
|
||||
one of the red rows below is a stop that will still be there when shadow mode
|
||||
ends. That row says how to clear it.
|
||||
|
||||
In **setup** (a new install that names no inverter yet) the banner says
|
||||
nothing is controlled yet, and the one row says to finish Setup. See *Setup,
|
||||
for a new install*.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||
@@ -3223,8 +3300,9 @@ ends. That row says how to clear it.
|
||||
|
||||
### For installers: status page
|
||||
|
||||
The texts are in `app/messages.py`. The full figures of any alarm are on its
|
||||
ERROR or WARNING line in the add-on log.
|
||||
The texts are in `app/messages.py`. An alarm's ERROR or WARNING line names its
|
||||
reason code, and its full figures are on the DEBUG line right after it and in
|
||||
`events` in the `status` JSON.
|
||||
|
||||
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
|
||||
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
|
||||
@@ -3246,9 +3324,14 @@ ERROR or WARNING line in the add-on log.
|
||||
check** SAFETY-42.
|
||||
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
|
||||
TARIFF-13. The store's state at start is the INFO line `Month peak store:
|
||||
…`, and the peak guard's full reason is on its log line.
|
||||
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
|
||||
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
|
||||
ERROR line carries the full `STALE_READING` reason and when it clears.
|
||||
ERROR line says when it clears and names `STALE_READING`. The full reason,
|
||||
with both possible causes, is on the DEBUG line after it.
|
||||
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
|
||||
meter goes quiet, "No meter reading for …", and an INFO line when readings
|
||||
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
|
||||
line names the missing reading.
|
||||
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
|
||||
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
|
||||
history**, **Arbitrage** and **Shadow mode** keep every figure they had
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -265,6 +265,34 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
# `slew_limit_w()` and therefore with the rail.
|
||||
SLEW_LEAD_STEPS = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
|
||||
#
|
||||
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
|
||||
# saturation freeze below holds the command where it stood (the recorded
|
||||
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
|
||||
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
|
||||
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
|
||||
# carried between cycles like `sat_count`, and left again when the loop wants
|
||||
# the other direction, when a small probe is answered, or when the caller
|
||||
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
|
||||
#
|
||||
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
|
||||
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
|
||||
# test_refusal.py pins the inequality. The cadence and length are the owner's
|
||||
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
|
||||
# (17 s is how long a healthy reading needs to show a command).
|
||||
REFUSED = "battery refuses"
|
||||
PROBING = "battery probe"
|
||||
PROBE_W = 200.0
|
||||
PROBE_ANSWER_W = PROBE_W / 2
|
||||
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
|
||||
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
|
||||
# (review of #141). The capture's refusals read -4 W.
|
||||
REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -346,6 +374,9 @@ class Decision:
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -442,6 +473,8 @@ def compute(
|
||||
tuning: Tuning,
|
||||
sat_count: int = 0,
|
||||
i_w: float | None = None,
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -449,6 +482,9 @@ def compute(
|
||||
`grid_w` net grid power, + = importing
|
||||
`actual_w` what the inverter reports it is doing, + = discharging
|
||||
`i_w` the carried integrator, or None to seed it from `prev_w`
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
"""
|
||||
reason = "tracking"
|
||||
|
||||
@@ -739,6 +775,27 @@ def compute(
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
wants = refused * i_w > 0
|
||||
i_w = 0.0
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||
@@ -749,7 +806,7 @@ def compute(
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
float(clamped_w), refused)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
|
||||
|
||||
A read of `GET /api/states` through the add-on's own session (hass.states()),
|
||||
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
|
||||
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
|
||||
served with its age, or an empty one that says why: the route never fails.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
CACHE_S = 30.0
|
||||
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
||||
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
||||
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
|
||||
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||
|
||||
|
||||
def row(state: dict) -> dict | None:
|
||||
"""One state object -> {entity_id, name, state, unit, device_class}."""
|
||||
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
|
||||
return None
|
||||
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
|
||||
return {"entity_id": state["entity_id"],
|
||||
"name": str(attrs.get("friendly_name") or state["entity_id"]),
|
||||
"state": state.get("state"),
|
||||
"unit": attrs.get("unit_of_measurement"),
|
||||
"device_class": attrs.get("device_class")}
|
||||
|
||||
|
||||
def watts(r: dict | None):
|
||||
"""A power entity's reading in W, or None when it is not a number in a
|
||||
power unit (the setup wizard's kettle test)."""
|
||||
if not r or r.get("unit") not in _TO_W:
|
||||
return None
|
||||
try:
|
||||
value = float(r.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value * _TO_W[r["unit"]] if value == value else None
|
||||
|
||||
|
||||
def matches(r: dict, kind: str) -> bool:
|
||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||
unit, cls = r.get("unit"), r.get("device_class")
|
||||
if kind in ("power", "power_w"):
|
||||
return cls == "power" or unit in _POWER_UNITS
|
||||
if kind == "energy":
|
||||
return cls == "energy" or unit in _ENERGY_UNITS
|
||||
if kind == "pct":
|
||||
return unit == "%"
|
||||
if kind == "number":
|
||||
return r["entity_id"].startswith(("number.", "input_number."))
|
||||
return True
|
||||
|
||||
|
||||
class EntityCache:
|
||||
"""The last entity list read from Home Assistant, at most CACHE_S old."""
|
||||
|
||||
def __init__(self, hass, clock=time.monotonic):
|
||||
self.hass = hass
|
||||
self.clock = clock
|
||||
self.rows: dict | None = None
|
||||
self.read_at: float | None = None
|
||||
self.tried_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, max_age: float = CACHE_S
|
||||
) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read.
|
||||
`max_age` is how old a try may be before HA is asked again: the setup
|
||||
wizard's last step asks for a list at most a few seconds old."""
|
||||
async with self._lock: # one HA read however many pickers ask
|
||||
now = self.clock()
|
||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||
# not queue one 10 s read each behind this lock.
|
||||
if self.tried_at is None or now - self.tried_at >= max_age:
|
||||
self.tried_at = now
|
||||
states = await self.hass.states()
|
||||
if states is not None:
|
||||
rows = (row(s) for s in states)
|
||||
self.rows = {r["entity_id"]: r for r in rows if r}
|
||||
self.read_at = now
|
||||
if self.read_at is None:
|
||||
return None, None
|
||||
return self.rows, now - self.read_at
|
||||
|
||||
async def reading(self, entity_id: str) -> dict:
|
||||
"""One entity read now, not from the list: the kettle test compares
|
||||
two readings seconds apart, which a 30 s list cannot. A read only."""
|
||||
r = row(await self.hass.state(entity_id)) if entity_id else None
|
||||
return {"entity_id": entity_id, "found": r is not None,
|
||||
"state": r and r["state"], "unit": r and r["unit"],
|
||||
"watts": watts(r)}
|
||||
|
||||
def peek(self) -> dict | None:
|
||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
||||
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
||||
return None
|
||||
return self.rows
|
||||
|
||||
async def payload(self, kind: str = "") -> dict:
|
||||
"""The route's JSON: matching entities first, then the rest."""
|
||||
rows, age = await self.get()
|
||||
if rows is None:
|
||||
return {"available": False, "text": DOWN_TEXT, "entities": []}
|
||||
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
|
||||
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
|
||||
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
|
||||
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
|
||||
if age >= CACHE_S:
|
||||
out["text"] = STALE_TEXT
|
||||
return out
|
||||
@@ -18,6 +18,7 @@ SUPERVISOR_API = "http://supervisor"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
|
||||
|
||||
class Outage:
|
||||
@@ -50,10 +51,18 @@ class Outage:
|
||||
OUTAGE = Outage()
|
||||
|
||||
|
||||
class ReadOnly(RuntimeError):
|
||||
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
|
||||
|
||||
|
||||
class HomeAssistant:
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
|
||||
*, read_only: bool = False):
|
||||
self.session = session
|
||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
# UX-15: setup-only mode reads Home Assistant and calls no service.
|
||||
# Fixed at construction; `call()` refuses before it sends anything.
|
||||
self._read_only = bool(read_only)
|
||||
self._last_moan: dict[str, float] = {}
|
||||
if not self.token:
|
||||
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
|
||||
@@ -113,6 +122,39 @@ class HomeAssistant:
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def states(self) -> list | None:
|
||||
"""Every entity's state object (`GET /api/states`), or None when Home
|
||||
Assistant does not answer. A read only (UX-13 entity picker)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states", headers=self._headers,
|
||||
timeout=STATES_TIMEOUT_S
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read all states: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read all states: {err}")
|
||||
return None
|
||||
# A timeout's own text is empty, so the line names the wait instead
|
||||
# (UX-13 P1 test, c).
|
||||
except TimeoutError:
|
||||
self._moan("read:*", "Reading the entity list failed: no answer "
|
||||
"within %s seconds.", STATES_TIMEOUT_S)
|
||||
return None
|
||||
except (aiohttp.ClientError, ValueError) as err:
|
||||
self._moan("read:*", "Reading the entity list failed: %s",
|
||||
str(err) or type(err).__name__)
|
||||
return None
|
||||
if not isinstance(obj, list):
|
||||
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def number(self, entity_id: str, invert: bool = False) -> float | None:
|
||||
"""Numeric state, or None. Never substitutes a default."""
|
||||
obj = await self.state(entity_id)
|
||||
@@ -147,7 +189,17 @@ class HomeAssistant:
|
||||
return None
|
||||
return (_f("min"), _f("max"), _f("step"))
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self._read_only
|
||||
|
||||
async def call(self, domain: str, service: str, data: dict) -> bool:
|
||||
if self._read_only:
|
||||
# Every service call, `number.set_value` included, goes through
|
||||
# here, so setup-only mode has no way to command (DECISIONS UX-15).
|
||||
_LOG.error("Setup-only mode called no service, because it may not "
|
||||
"command anything.")
|
||||
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{CORE_API}/services/{domain}/{service}",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
|
||||
+469
-41
@@ -28,6 +28,7 @@ import contextlib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -39,6 +40,7 @@ from .arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
|
||||
Claim, Resolution, actuator_for, resolve,
|
||||
)
|
||||
from . import control as _ctl
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||
@@ -53,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,
|
||||
@@ -84,9 +87,11 @@ from .watchdog import (
|
||||
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
from . import setuponly
|
||||
from . import web
|
||||
|
||||
OPTIONS_PATH = "/data/options.json"
|
||||
INGRESS_PORT = 8099
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
|
||||
@@ -119,6 +124,9 @@ WRITE_BUDGET_S = 4.0
|
||||
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||
REANNOUNCE_S = 300.0
|
||||
# UX-11 review: a quiet meter logs at most one WARNING this often, because on
|
||||
# `ha_signed` a healthy meter under a flat load can go 42-97 s between changes.
|
||||
METER_QUIET_REPEAT_S = 600.0
|
||||
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -230,6 +238,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.
|
||||
|
||||
@@ -238,6 +260,10 @@ def why_key(winner: str, claims) -> str:
|
||||
disagreement"). A failsafe with no claim of its own is the arbiter's "no
|
||||
claim was made", or main.py's refusal to split the power ("unsplit").
|
||||
"""
|
||||
if winner == "loop":
|
||||
# SAFETY-52: a loop holding a refused direction says so.
|
||||
return next((c.reason for c in claims if c.source == "loop"
|
||||
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
|
||||
if winner in ("safety", "failsafe"):
|
||||
return next((c.reason for c in claims
|
||||
if c.source == winner and c.kind == "set"),
|
||||
@@ -351,6 +377,11 @@ class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
price_history_path: str = "/data/price_history.json"):
|
||||
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
|
||||
# builds no controller. Refused here so no controller can hold one.
|
||||
if getattr(hass, "read_only", False) is True:
|
||||
raise ValueError("a controller cannot run on a read-only "
|
||||
"HomeAssistant (setup-only mode)")
|
||||
self.o = opts
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
@@ -611,10 +642,21 @@ class Controller:
|
||||
self._grade_store_migration(store)
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
# UX-11: the plain sentence and the code. The saved reason,
|
||||
# with its figures, is on the DEBUG line beside it.
|
||||
_LOG.error("%s stays at 0 W after the restart because its "
|
||||
"power went the wrong way, so find out why before "
|
||||
"you start control. %s.", inv.name,
|
||||
"you start control: %s.", inv.name,
|
||||
messages.reason_code(inv.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
# UX-11 review: `events` starts empty and DEBUG is off by
|
||||
# default, so the saved figures go on the action log too.
|
||||
saved = inv.sign_wd.reason or messages.NO_REASON_SAVED
|
||||
self.log_event(f"FAILSAFE {inv.name}, saved before the "
|
||||
f"restart: {saved}",
|
||||
hint=messages.hint("stop_restored",
|
||||
unit=inv.name))
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
@@ -637,8 +679,15 @@ class Controller:
|
||||
_LOG.error(
|
||||
"%s is still marked as not delivering after the restart "
|
||||
"while control keeps running, and only stopping and "
|
||||
"starting control clears this. %s.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
"starting control clears this: %s.",
|
||||
inv.name, messages.reason_code(inv.delivery_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.delivery_wd.reason or "(reason not recorded)")
|
||||
saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED
|
||||
self.log_event(f"NOT DELIVERING {inv.name}, saved before the "
|
||||
f"restart: {saved}",
|
||||
hint=messages.hint("not_delivering",
|
||||
unit=inv.name))
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
@@ -669,8 +718,13 @@ class Controller:
|
||||
_LOG.error(
|
||||
"After the restart every inverter stays at 0 W, as the total "
|
||||
"power went the wrong way, so find out why before starting "
|
||||
"control. %s.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
self.log_event("FAILSAFE site, saved before the restart: "
|
||||
+ (self.site_sign_wd.reason
|
||||
or messages.NO_REASON_SAVED),
|
||||
hint=messages.hint("site_direction"))
|
||||
# And the per-unit detectors are told which question they are asking,
|
||||
# here as well as in `cycle()`. `checks()` and the status page read
|
||||
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
|
||||
@@ -722,8 +776,14 @@ class Controller:
|
||||
_LOG.error(
|
||||
"After the restart every inverter stays at 0 W, as the "
|
||||
"inverters worked against each other, so find out why before "
|
||||
"starting control. %s.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
"starting control: %s.",
|
||||
messages.reason_code(self.circ_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
self.log_event("CIRCULATING POWER, saved before the restart: "
|
||||
+ (self.circ_wd.reason
|
||||
or messages.NO_REASON_SAVED),
|
||||
hint=messages.hint("circulating"))
|
||||
|
||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||
# itself is the cheap half.
|
||||
@@ -893,6 +953,12 @@ class Controller:
|
||||
self.target = 0.0
|
||||
self.sat_count = 0
|
||||
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
||||
# SAFETY-52. The direction the battery refuses (+1 / -1 / 0), carried
|
||||
# like `i_w`, and when the refusal (or its last probe) started.
|
||||
self.batt_refused = 0
|
||||
self.batt_refused_at = None
|
||||
self._now_mono = None
|
||||
self._refusal_auto = False
|
||||
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
|
||||
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
|
||||
# because before the first cycle the loop has not run - which is the
|
||||
@@ -929,6 +995,11 @@ class Controller:
|
||||
self.following = False
|
||||
self.reason = "starting"
|
||||
self.why_words = "Starting"
|
||||
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
|
||||
self._meter_quiet_logged = False
|
||||
# ...when the last WARNING was, and whether this episode logged one.
|
||||
self._meter_quiet_warned = None
|
||||
self._meter_quiet_loud = False
|
||||
# ALLOC-01. The allocation record from the last resolution, or None
|
||||
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
|
||||
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
|
||||
@@ -999,6 +1070,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
|
||||
@@ -1021,6 +1095,192 @@ class Controller:
|
||||
self.loop_pass_mono = None
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
# --- SAFETY-52: a battery that refuses is asked for 0 W ------------------
|
||||
def _refusal_probe(self) -> bool:
|
||||
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
|
||||
if (not self.batt_refused or self.batt_refused_at is None
|
||||
or self._now_mono is None):
|
||||
return False
|
||||
held = self._now_mono - self.batt_refused_at
|
||||
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
|
||||
self.batt_refused_at = self._now_mono
|
||||
return False
|
||||
return held >= _ctl.PROBE_EVERY_S
|
||||
|
||||
def _refusal_start(self, sign: int, evidence: str) -> None:
|
||||
self.batt_refused = sign
|
||||
self.batt_refused_at = self._now_mono
|
||||
self.log_event(f"BATTERY REFUSES {evidence}",
|
||||
hint=messages.hint("battery_refuses"))
|
||||
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
|
||||
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
|
||||
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
|
||||
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
|
||||
|
||||
def _refusal_end(self, why: str) -> None:
|
||||
if not self.batt_refused:
|
||||
return
|
||||
self.batt_refused, self.batt_refused_at = 0, None
|
||||
self.log_event(f"BATTERY REFUSES ENDED: {why}",
|
||||
hint=messages.hint("battery_gives"))
|
||||
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||
why)
|
||||
|
||||
def _refusal_update(self, decision, asked_w, batt_w, probe) -> None:
|
||||
"""Follow `compute()`'s carried refused direction, and say so."""
|
||||
now = decision.refused or 0
|
||||
if now and not self.batt_refused:
|
||||
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
|
||||
def _refusal_from_stale(self, inv, now_mono) -> None:
|
||||
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
|
||||
|
||||
Only at one inverter: with several, one empty battery must not stop the
|
||||
site (OPT-09's pack limit moves its share to the others).
|
||||
"""
|
||||
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
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
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
|
||||
@@ -1229,8 +1489,9 @@ class Controller:
|
||||
first.store_set("sign_tripped", True)
|
||||
_LOG.error(
|
||||
"%s is set to 0 W again because an older saved safety stop was "
|
||||
"found, so find out why before you start control. %s.",
|
||||
first.name, first.sign_wd.reason)
|
||||
"found, so find out why before you start control: %s.",
|
||||
first.name, messages.reason_code(first.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||
"sign-disagreement failsafe is latched and no inverter came up "
|
||||
@@ -1726,6 +1987,18 @@ class Controller:
|
||||
return all(inv.soc is not None and inv.batt is not None
|
||||
for inv in self.inverters)
|
||||
|
||||
def missing_reading(self) -> str:
|
||||
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return "meter"
|
||||
for inv in self.inverters:
|
||||
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
|
||||
if inv.batt is None:
|
||||
return f"battery power{of}"
|
||||
if inv.soc is None:
|
||||
return f"battery level{of}"
|
||||
return "input"
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
@@ -1790,6 +2063,7 @@ class Controller:
|
||||
"""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
self._now_mono = now_mono
|
||||
if self.p1_enabled:
|
||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||
# sees (§5.1). When it is stale this is None, which falls into the
|
||||
@@ -1885,6 +2159,34 @@ class Controller:
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
# UX-11: the red meter row says "the add-on log shows why", so a meter
|
||||
# that goes quiet gets one WARNING per episode, not one per cycle. It
|
||||
# is a log line only: no notification, and nothing it decides.
|
||||
# A flapping meter logs at most one WARNING per METER_QUIET_REPEAT_S,
|
||||
# and the episodes in between go to DEBUG.
|
||||
if self.p1_enabled:
|
||||
if (self.p1.stale and not self._meter_quiet_logged
|
||||
and self.p1.published_age_s > self.p1.max_age_s):
|
||||
self._meter_quiet_logged = True
|
||||
now = _mono()
|
||||
self._meter_quiet_loud = (
|
||||
self._meter_quiet_warned is None
|
||||
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
|
||||
if self._meter_quiet_loud:
|
||||
self._meter_quiet_warned = now
|
||||
_LOG.warning("No meter reading for over %s, so control "
|
||||
"waits and then sets the battery to 0 W.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
else:
|
||||
_LOG.debug("No meter reading for over %s again.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
elif not self.p1.stale and self._meter_quiet_logged:
|
||||
self._meter_quiet_logged = False
|
||||
if self._meter_quiet_loud:
|
||||
_LOG.info("Meter readings are back.")
|
||||
else:
|
||||
_LOG.debug("Meter readings are back.")
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered",
|
||||
@@ -1925,6 +2227,7 @@ class Controller:
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
self._refusal_from_stale(inv, now_mono)
|
||||
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||
# moment the register reports a different value", full stop - and
|
||||
@@ -1953,13 +2256,16 @@ class Controller:
|
||||
# learns to skim it.
|
||||
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
||||
# ordered pair (time, then power), and the reason still in `%s`.
|
||||
# UX-10: one sentence, then the reason's two. Its limits are on
|
||||
# the DEBUG line below, to keep the line within 3 sentences.
|
||||
# UX-11: one sentence and the code. The reason, with SAFETY-56's
|
||||
# two explanations and its figures, and the limits are on the
|
||||
# DEBUG lines below and in the action log.
|
||||
_LOG.error("%s: battery power reading stopped changing, set to "
|
||||
"0 W until it changes, or %.0f s after the command "
|
||||
"drops below %.0f W. %s.",
|
||||
"drops below %.0f W: %s.",
|
||||
inv.name, inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
|
||||
"over %.0f W and a move by over %.0f W.", inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
@@ -2359,7 +2665,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:
|
||||
@@ -2491,8 +2798,10 @@ class Controller:
|
||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
||||
hint=messages.hint("direction", unit=inv.name))
|
||||
_LOG.error("%s is set to 0 W until control is started, "
|
||||
"because its power went the wrong way. %s.",
|
||||
inv.name, inv.sign_wd.reason)
|
||||
"because its power went the wrong way: %s.",
|
||||
inv.name, messages.reason_code(inv.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.sign_wd.reason)
|
||||
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
|
||||
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
|
||||
# average at the grid connection, and a meter that has stopped moving
|
||||
@@ -2540,7 +2849,10 @@ class Controller:
|
||||
hint=messages.hint("site_direction"))
|
||||
_LOG.error("Every inverter is set to 0 W until control is "
|
||||
"started, because the total power went the wrong "
|
||||
"way. %s.", self.site_sign_wd.reason)
|
||||
"way: %s.",
|
||||
messages.reason_code(self.site_sign_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.site_sign_wd.reason)
|
||||
|
||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
||||
@@ -2572,8 +2884,9 @@ class Controller:
|
||||
hint=messages.hint("circulating"))
|
||||
_LOG.error(
|
||||
"Every inverter is set to 0 W until control is started, "
|
||||
"because the inverters worked against each other. %s.",
|
||||
self.circ_wd.reason)
|
||||
"because the inverters worked against each other: %s.",
|
||||
messages.reason_code(self.circ_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
@@ -2647,7 +2960,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
|
||||
@@ -2680,8 +2999,10 @@ class Controller:
|
||||
unit=inv.name))
|
||||
_LOG.error(
|
||||
"%s is not delivering while control keeps running, and "
|
||||
"only stopping and starting control clears this. %s.",
|
||||
inv.name, inv.delivery_wd.reason)
|
||||
"only stopping and starting control clears this: %s.",
|
||||
inv.name, messages.reason_code(inv.delivery_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.delivery_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-42 reports and does not act: commanding a unit "
|
||||
"that is not listening achieves nothing (HW-01), and the "
|
||||
@@ -3251,6 +3572,22 @@ class Controller:
|
||||
# loop should be. It is safe because the loop's `prev_w` is
|
||||
# `self.target`, which the end of this method re-seeds to what is
|
||||
# actually standing on the wire.
|
||||
# SAFETY-52: a refusal learned while control ran does not survive a
|
||||
# stop or a start (shadow mode keeps the loop running when stopped).
|
||||
if bool(self.auto) != self._refusal_auto:
|
||||
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
|
||||
@@ -3268,6 +3605,15 @@ class Controller:
|
||||
# stale period as power.
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
self.i_w = self.target
|
||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||
# against the loop's own ask no longer describes the wire. A gap
|
||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||
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
|
||||
@@ -3296,6 +3642,13 @@ class Controller:
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
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.
|
||||
# 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:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
@@ -3304,6 +3657,8 @@ class Controller:
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
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
|
||||
@@ -3402,8 +3757,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
|
||||
@@ -3554,9 +3945,14 @@ class Controller:
|
||||
# UX-10: `why_key()` maps a safety winner to its claim's reason, and
|
||||
# every key it can return has words in messages.WHY_WINNER.
|
||||
_why = why_key(resolution.winner, claims)
|
||||
self.why_words = messages.why(_why, resolution.target_w,
|
||||
resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
if _why == "inputs missing" and not resolution.contradiction:
|
||||
# UX-11: past the hold, say which reading is missing.
|
||||
self.why_words = messages.why_missing(self.missing_reading(),
|
||||
stale_after)
|
||||
else:
|
||||
self.why_words = messages.why(
|
||||
_why, resolution.target_w, resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
|
||||
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
||||
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
||||
@@ -3743,8 +4139,8 @@ class Controller:
|
||||
shadow.why(self.o, control_started=self.auto))
|
||||
elif not first:
|
||||
self.log_event(
|
||||
"SHADOW MODE OFF - the battery is being controlled again, "
|
||||
f"resuming from {shadow.WIRE_W:.0f} W",
|
||||
"Shadow mode off: the add-on controls the battery again, "
|
||||
f"starting from {shadow.WIRE_W:.0f} W",
|
||||
hint=messages.hint("shadow_off"))
|
||||
_LOG.info(
|
||||
"Shadow mode is off, so the add-on controls the battery "
|
||||
@@ -3856,8 +4252,9 @@ class Controller:
|
||||
# write-path engagements and 2 loop-side ones.
|
||||
if engaged and not engaged_write:
|
||||
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
||||
f"{reason}")
|
||||
row = (f"{tag}: "
|
||||
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
|
||||
f" - {reason}")
|
||||
if self.events and tag in self.events[0]:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
@@ -3871,8 +4268,9 @@ class Controller:
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("The battery gives less than asked: %s. Held back %s "
|
||||
"so far.", self.clamp_wd.reason,
|
||||
"so far.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug("A short hold after a large load or solar step is "
|
||||
"normal. If it lasts, check that the battery follows "
|
||||
"its commands.")
|
||||
@@ -3881,8 +4279,9 @@ class Controller:
|
||||
return
|
||||
_LOG.error(
|
||||
"Power request too high, a software fault: %s. Cut back %s so "
|
||||
"far, control runs on.",
|
||||
self.clamp_wd.reason, messages.times(self.clamp_wd.engagements))
|
||||
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before. The clamp makes every "
|
||||
@@ -3931,7 +4330,9 @@ class Controller:
|
||||
hint=messages.hint("peak_saturated"))
|
||||
_LOG.error(
|
||||
"Switch off large loads, the battery alone cannot keep this "
|
||||
"15-minute period under the peak limit. %s.", g.reason)
|
||||
"15-minute period under the peak limit: %s.",
|
||||
messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
@@ -3988,7 +4389,8 @@ class Controller:
|
||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||
_LOG.error(
|
||||
"Switch off large loads, the peak guard may not draw the battery "
|
||||
"lower. %s.", g.reason)
|
||||
"lower: %s.", messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||
@@ -4148,8 +4550,9 @@ class Controller:
|
||||
hint=messages.hint("meter_frozen"))
|
||||
_LOG.error(
|
||||
"The meter shows the same value while the battery is asked for "
|
||||
"full power, and control still follows it, so check the meter. "
|
||||
"%s.", self.freeze_wd.reason)
|
||||
"full power, and control still follows it, so check the meter: "
|
||||
"%s.", messages.reason_code(self.freeze_wd.reason))
|
||||
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||
@@ -4919,7 +5322,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})
|
||||
|
||||
@@ -5313,7 +5717,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
|
||||
@@ -5322,6 +5726,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)
|
||||
@@ -5599,6 +6018,15 @@ async def amain() -> None:
|
||||
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
||||
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
||||
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
||||
# UX-15: a new install names no inverter. When that is the ONLY
|
||||
# refusal it serves the setup wizard in setup-only mode, which builds
|
||||
# nothing that commands and returns when stopped. Any other refusal
|
||||
# still exits 78 (DECISIONS/UX-15-setup-only-mode.md).
|
||||
if _problems and setuponly.applies(opts, _problems):
|
||||
await setuponly.serve(
|
||||
opts, HomeAssistant(session, read_only=True), store,
|
||||
port=INGRESS_PORT)
|
||||
return
|
||||
if _problems:
|
||||
refuse(_problems)
|
||||
raise ConfigRefused(_problems)
|
||||
@@ -5642,7 +6070,7 @@ async def amain() -> None:
|
||||
|
||||
stop = asyncio.Event()
|
||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||
runner = await web.start(controller, port=8099)
|
||||
runner = await web.start(controller, port=INGRESS_PORT)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
|
||||
@@ -311,6 +311,22 @@ def seconds(value) -> str:
|
||||
return f"{n} second" if n == "1" else f"{n} seconds"
|
||||
|
||||
|
||||
# One token each, like a code, so the line keeps its length and stays greppable.
|
||||
NO_REASON_SAVED, NO_REASON_CODE = "REASON_NOT_SAVED", "REASON_WITHOUT_CODE"
|
||||
_REASON_CODE = re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b")
|
||||
|
||||
|
||||
def reason_code(reason) -> str:
|
||||
"""UX-11: the code in a safety reason (`STALE_READING`), for the one plain
|
||||
WARNING or ERROR line that reports it. The reason with its figures goes on
|
||||
a DEBUG line beside it and into the action log. Saved reasons have always
|
||||
opened with their code, so an old state file reads the same way."""
|
||||
if not reason:
|
||||
return NO_REASON_SAVED
|
||||
m = _REASON_CODE.search(str(reason))
|
||||
return m.group(0) if m else NO_REASON_CODE
|
||||
|
||||
|
||||
def times(n) -> str:
|
||||
return "1 time" if int(n) == 1 else f"{int(n)} times"
|
||||
|
||||
@@ -366,6 +382,16 @@ def banner_stopped() -> str:
|
||||
return "Stopped — the inverters are set to 0 W"
|
||||
|
||||
|
||||
def banner_setup_only() -> str:
|
||||
"""UX-15: setup-only mode, where the add-on serves the page and commands
|
||||
nothing (app/setuponly.py)."""
|
||||
return "Setup: nothing is controlled yet"
|
||||
|
||||
|
||||
def row_setup_only() -> str:
|
||||
return "No inverter is named yet, so finish Setup and restart the add-on."
|
||||
|
||||
|
||||
def banner_shadow(latched: bool) -> str:
|
||||
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
||||
|
||||
@@ -770,6 +796,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",
|
||||
@@ -786,6 +820,11 @@ WHY_WINNER = {
|
||||
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
||||
"sign disagreement": "Safety stop, power went the wrong way",
|
||||
"unsplit": "The power could not be split, all at 0 W",
|
||||
# SAFETY-52: the loop's two words for a battery that gives nothing.
|
||||
"battery refuses": "The battery refuses to give power",
|
||||
"battery probe": "Testing if the battery gives power again",
|
||||
# SAFETY-73: a refusing inverter's own claim, closed or probing.
|
||||
"refused": "A battery refuses power, the others take over",
|
||||
}
|
||||
WHY_LIMIT = {
|
||||
"peak(quarter peak)": "the peak limit",
|
||||
@@ -796,6 +835,7 @@ WHY_LIMIT = {
|
||||
"pack": "the battery level",
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
"refused": "a battery that refuses power",
|
||||
}
|
||||
|
||||
|
||||
@@ -813,6 +853,11 @@ def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
|
||||
return out
|
||||
|
||||
|
||||
def why_missing(what, limit_s) -> str:
|
||||
"""UX-11: past the hold, which reading is missing and what it does."""
|
||||
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
@@ -826,11 +871,16 @@ HINTS = {
|
||||
"control_stopped": "Control stopped",
|
||||
"settings_saved": "Settings saved, they apply after a restart",
|
||||
"restart_requested": "Restart requested from the settings page",
|
||||
"setup_only": "In setup, nothing is sent to any inverter",
|
||||
"roster": "Inverter list read: {n} inverters",
|
||||
"maint_history": "Maintenance history did not move over cleanly",
|
||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||
"battery_back": "{unit}: battery power reading is used again",
|
||||
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
|
||||
"battery_gives": "The battery refusal ended, control asks for power again",
|
||||
"unit_refuses": "{unit}: the battery refuses power, the others take over",
|
||||
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
|
||||
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
|
||||
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
|
||||
"all_blocked": "A power request was blocked, inverters set to 0 W",
|
||||
@@ -913,6 +963,8 @@ def status_samples() -> list:
|
||||
("banner_peak_reserve", None, banner_peak_reserve()),
|
||||
("banner_power_too_high", None, banner_power_too_high()),
|
||||
("banner_shadow", None, banner_shadow(False)),
|
||||
("banner_setup_only", None, banner_setup_only()),
|
||||
("row_setup_only", AMBER, row_setup_only()),
|
||||
("banner_shadow", None, banner_shadow(True)),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
@@ -1001,6 +1053,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)),
|
||||
]
|
||||
|
||||
@@ -1073,6 +1131,18 @@ LABELS = {
|
||||
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
|
||||
"slew_w": "Largest change per cycle",
|
||||
"shadow_mode": "Shadow mode",
|
||||
# UX-13: named by the settings page's warnings (validate.Note).
|
||||
"integrator_max_w": "Catch-up limit",
|
||||
"maintenance_discharge_w": "Draw-down rate",
|
||||
"peak_cap_w": "Peak limit",
|
||||
"peak_forecast_entity": "15-minute demand forecast entity",
|
||||
"meter_entity": "Grid power entity",
|
||||
"soc_entity": "Battery level entity",
|
||||
"batt_entity": "Battery power entity",
|
||||
"pv_entity": "Solar production entity",
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -131,11 +131,16 @@ class SaveRefused(Exception):
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None,
|
||||
moved=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
||||
# against stored-now - when the caller knows what it opened. None
|
||||
# when it does not. `changed` also holds the operator's own edits.
|
||||
self.moved = None if moved is None else list(moved)
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
@@ -288,7 +293,7 @@ async def problems_for(opts: dict, hass=None) -> list:
|
||||
return problems
|
||||
|
||||
|
||||
async def problems_and_ranges(opts: dict, hass=None):
|
||||
async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
|
||||
|
||||
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
|
||||
@@ -303,6 +308,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
has just typed a new `setpoint_entity` must be told about THAT entity, and
|
||||
a route that asked the running controller instead would report on the old
|
||||
one. That was the first shape of this and it disagreed with itself.
|
||||
|
||||
UX-13: a list passed as `notes` collects the validator's warnings too.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
|
||||
@@ -329,7 +336,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
# disagreement is itself the finding - so it is reported rather than
|
||||
# swallowed. Returning `[]` here would turn a crash into a silent
|
||||
# acceptance, which is worse than the 500 this replaced.
|
||||
found = list(validate_startup_config(opts))
|
||||
found = list(validate_startup_config(opts, notes=notes))
|
||||
if not found:
|
||||
found = [f"the configuration could not be composed ({type(exc).__name__}: "
|
||||
f"{exc}), and the validator did not name a reason. This is a "
|
||||
@@ -347,7 +354,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
|
||||
dev_min, dev_max = unit.dev_min, unit.dev_max
|
||||
break
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max))
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max,
|
||||
notes=notes))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
@@ -356,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
hass=None, base=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
@@ -379,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
|
||||
`base`, when given, is the blob the page opened (the one `expect` is the
|
||||
fingerprint of). A stale refusal then also names the keys that moved
|
||||
underneath it, so the page does not list the operator's own edits among
|
||||
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
@@ -392,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
moved = (changed_keys(base, stored)
|
||||
if isinstance(base, dict) and fingerprint(base) == expect
|
||||
else None)
|
||||
keys = changed if moved is None else moved
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
", ".join(keys) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||
moved=moved)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
|
||||
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
|
||||
|
||||
`applies()` says when: the startup gate's only refusal is that no inverter is
|
||||
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
|
||||
controller. Nothing that can command is built: no Controller, no inverter, no
|
||||
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
|
||||
before anything is sent (hass.py), and `/action` refuses too (web.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import messages, web
|
||||
from .inverter import roster_entries
|
||||
from .validate import actuator_writers, no_power_request_problem
|
||||
|
||||
_LOG = logging.getLogger("goodwe.setup")
|
||||
|
||||
|
||||
def applies(opts: dict, problems) -> bool:
|
||||
"""True when the options name no inverter at all and that is the gate's
|
||||
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
|
||||
return (not actuator_writers(opts) and not roster_entries(opts)
|
||||
and list(problems) == [no_power_request_problem()])
|
||||
|
||||
|
||||
class SetupOnly:
|
||||
"""What web.build_app() needs from a controller, and nothing that writes.
|
||||
|
||||
It holds no inverter and no publisher. `hass` must be read-only."""
|
||||
|
||||
setup_only = True
|
||||
auto = False # control is never started here
|
||||
grid = None # no meter is read here
|
||||
inverters = ()
|
||||
|
||||
def __init__(self, opts: dict, hass, store):
|
||||
if getattr(hass, "read_only", False) is not True:
|
||||
raise ValueError("setup-only mode needs a read-only HomeAssistant")
|
||||
self.o, self.hass, self.store = opts, hass, store
|
||||
self.events: list[str] = []
|
||||
self.hint = ""
|
||||
|
||||
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
self.events.insert(0, f"{stamp} {msg}")
|
||||
del self.events[40:]
|
||||
if hint is not None:
|
||||
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.row_setup_only()}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
# web.py answers 409 before it gets here. Nothing is done either way.
|
||||
_LOG.warning("The page asked for an action in setup, so nothing "
|
||||
"was done.")
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Nothing to wind down: nothing was ever commanded."""
|
||||
|
||||
|
||||
async def serve(opts: dict, hass, store, *, port: int) -> None:
|
||||
"""Serve the page in setup-only mode until the Supervisor stops us."""
|
||||
controller = SetupOnly(opts, hass, store)
|
||||
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
|
||||
"controls nothing. Open its page to finish setup.")
|
||||
controller.log_event("setup-only mode: no inverter named, nothing is "
|
||||
"commanded", hint=messages.hint("setup_only"))
|
||||
runner = await web.start(controller, port=port)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
try:
|
||||
await stop.wait()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
_LOG.info("stopped")
|
||||
@@ -0,0 +1,226 @@
|
||||
/* 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}
|
||||
/* UX-13 P4: the setup wizard */
|
||||
.steps{display:flex;gap:4px;margin:4px 0 12px}
|
||||
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
|
||||
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
|
||||
.msg.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
|
||||
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
|
||||
.kettle .row{margin-top:8px}
|
||||
@@ -0,0 +1,382 @@
|
||||
"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;
|
||||
// UX-15: setup-only mode commands nothing and has no control to offer.
|
||||
const setup = S.setup_only === true;
|
||||
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
|
||||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
renderControl(structured, setup);
|
||||
renderDetails(!structured && !setup);
|
||||
renderBatteries();
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
function val(id, w, show) {
|
||||
const e = $(id);
|
||||
if (w == null || !show) { e.textContent = "–"; return; }
|
||||
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
|
||||
}
|
||||
function levelBar(pct, label) {
|
||||
const known = pct != null;
|
||||
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
|
||||
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
|
||||
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
|
||||
}
|
||||
function renderNow() {
|
||||
const n = S.now;
|
||||
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
|
||||
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
|
||||
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
|
||||
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
|
||||
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
|
||||
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
|
||||
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||||
}
|
||||
|
||||
/* ---------------- Home: today's plan ---------------- */
|
||||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||||
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
|
||||
["peak", "background:var(--bad)"], ["house", null]];
|
||||
function tipText(lines) { return lines.filter(Boolean).join(" "); }
|
||||
function chartItem(tag, cls, style, title, lines) {
|
||||
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
|
||||
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
|
||||
}
|
||||
function roving(group) {
|
||||
const items = [...group.querySelectorAll("[data-tip]")];
|
||||
items.forEach((el, i) => {
|
||||
el.tabIndex = i === 0 ? 0 : -1;
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
|
||||
if (d === undefined) return;
|
||||
e.preventDefault();
|
||||
const j = Math.max(0, Math.min(items.length - 1, i + d));
|
||||
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderPlan() {
|
||||
const p = S.plan;
|
||||
$("#verdict").textContent = p.verdict;
|
||||
$("#plan-head").textContent = p.head;
|
||||
const hours = p.hours || 24;
|
||||
const bars = $("#bars");
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
}));
|
||||
roving(bars);
|
||||
$("#no-prices").hidden = known.length > 0;
|
||||
const lanes = $("#lanes");
|
||||
lanes.replaceChildren();
|
||||
S.batteries.forEach((b, i) => {
|
||||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||||
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
|
||||
for (const r of runs) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
|
||||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||||
}
|
||||
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
|
||||
roving(lane);
|
||||
});
|
||||
const nl = $("#nowline");
|
||||
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
|
||||
const used = new Set(p.runs.map((r) => r.kind));
|
||||
known.forEach((b) => used.add(b.kind));
|
||||
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
|
||||
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
|
||||
const list = [];
|
||||
S.batteries.forEach((b, i) => {
|
||||
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
|
||||
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||||
}
|
||||
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
|
||||
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
|
||||
});
|
||||
for (const line of p.lines) list.push(h("li", {}, line));
|
||||
$("#plan-list").replaceChildren(...list);
|
||||
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
|
||||
(function () {
|
||||
const tip = $("#tip"); let hot = null;
|
||||
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
|
||||
function show(el, x, y) {
|
||||
if (hot && hot !== el) hot.classList.remove("hot");
|
||||
hot = el; el.classList.add("hot");
|
||||
const lines = JSON.parse(el.dataset.tip);
|
||||
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
|
||||
tip.classList.add("show");
|
||||
const r = tip.getBoundingClientRect();
|
||||
let L = x + 12, T2 = y - r.height - 12;
|
||||
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
|
||||
if (L < 8) L = 8;
|
||||
if (T2 < 8) T2 = y + 16;
|
||||
tip.style.left = L + "px"; tip.style.top = T2 + "px";
|
||||
}
|
||||
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const el = target(e);
|
||||
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
|
||||
});
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
const el = target(e);
|
||||
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
|
||||
else if (!el) hide();
|
||||
});
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const el = target(e);
|
||||
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
|
||||
});
|
||||
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||||
addEventListener("scroll", hide, { passive: true });
|
||||
})();
|
||||
|
||||
/* ---------------- Home: peak, attention, control, details ---------------- */
|
||||
function renderPeak() {
|
||||
const m = S.month_peak;
|
||||
$("#peak-month").textContent = m.month;
|
||||
const covered = m.state !== "not_covered";
|
||||
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
|
||||
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
|
||||
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
|
||||
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
|
||||
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
|
||||
const bar = $("#peak-meter i");
|
||||
bar.style.width = pct + "%";
|
||||
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
|
||||
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
|
||||
$("#peak-line").textContent = m.line;
|
||||
}
|
||||
function renderAttention() {
|
||||
const list = S.attention;
|
||||
const icon = { alarm: "×", warning: "!", info: "i" };
|
||||
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
|
||||
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
|
||||
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
|
||||
h("div", { class: "small" }, a.body),
|
||||
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
|
||||
const n = list.filter((a) => a.severity !== "info").length;
|
||||
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
|
||||
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
|
||||
const badge = $("#attn-badge");
|
||||
badge.textContent = n; badge.hidden = !n;
|
||||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||||
}
|
||||
function renderControl(structured, setup) {
|
||||
const running = structured ? S.control.running : null;
|
||||
const btn = $("#btn-ctl");
|
||||
btn.textContent = running === false ? t("control.start") : t("control.stop");
|
||||
btn.className = "btn" + (running === false ? " primary" : "");
|
||||
btn.hidden = !!setup;
|
||||
$("#btn-setup").hidden = !setup;
|
||||
$("#c-ctl .more").hidden = !!setup;
|
||||
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
|
||||
$("#hint").textContent = S.hint || "";
|
||||
const docs = $("#docs-link");
|
||||
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
}
|
||||
function renderDetails(open) {
|
||||
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
|
||||
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
|
||||
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
|
||||
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
|
||||
return h("li", { class: cls }, mark + " " + c.text);
|
||||
}));
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries (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.
|
||||
// `first`: the page opened with no route, so settings.js may open the setup
|
||||
// wizard on a new install (UX-13 P4).
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
|
||||
|
||||
/* ---------------- 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();
|
||||
})();
|
||||
@@ -0,0 +1,156 @@
|
||||
<!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">☀</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">⌂</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">⇅</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">▮</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
|
||||
</div>
|
||||
<div id="home-batts" style="margin-top:6px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-plan">
|
||||
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
|
||||
<div class="small" id="plan-head"></div>
|
||||
<div class="chart" id="chart">
|
||||
<div class="bars" id="bars" role="group"></div>
|
||||
<div id="lanes"></div>
|
||||
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
|
||||
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
|
||||
</div>
|
||||
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
|
||||
<h3 class="sr" data-t="plan.list"></h3>
|
||||
<ul class="plain small" id="plan-list"></ul>
|
||||
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-peak">
|
||||
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
|
||||
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
|
||||
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
|
||||
<div class="tiny muted" id="peak-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-attn">
|
||||
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
|
||||
<div id="alerts"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-ctl">
|
||||
<h2 data-t="control.name"></h2>
|
||||
<div class="small" id="ctl-text"></div>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button class="btn" id="btn-ctl" type="button"></button>
|
||||
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
|
||||
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
|
||||
</div>
|
||||
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
|
||||
<details class="more">
|
||||
<summary data-t="control.more"></summary>
|
||||
<div class="row" style="padding-bottom:6px">
|
||||
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
|
||||
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<details class="card more" id="c-details">
|
||||
<summary data-t="details.name"></summary>
|
||||
<table class="rows" id="rows"></table>
|
||||
<h2 style="margin-top:12px" data-t="details.checks"></h2>
|
||||
<ul class="checks" id="checks"></ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||
<div id="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" id="wz">
|
||||
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
|
||||
<div class="steps" id="wz-steps" aria-hidden="true"></div>
|
||||
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
|
||||
<div id="wz-banner" aria-live="polite"></div>
|
||||
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
|
||||
<div class="row spread" id="wz-nav" style="margin-top:14px">
|
||||
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
|
||||
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tiny muted" data-s="setup.description"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
|
||||
<h2 id="dlg-t"></h2>
|
||||
<p class="small" id="dlg-b"></p>
|
||||
<div class="row" style="justify-content:flex-end">
|
||||
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
|
||||
<button class="btn primary" id="dlg-yes" type="button"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="savebar" role="region" hidden><div class="in">
|
||||
<span class="grow" id="sb-text" aria-live="polite"></span>
|
||||
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
|
||||
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
|
||||
</div></div>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<div id="tip" class="tip" aria-hidden="true"></div>
|
||||
|
||||
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
|
||||
<script>/*APP_JS*/</script>
|
||||
<script>/*SETTINGS_JS*/</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,814 @@
|
||||
"use strict";
|
||||
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
|
||||
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
|
||||
from app/uimodel.py), so this file names no option key: keys, labels,
|
||||
ranges and choices all arrive as data. It posts the page model back; the
|
||||
add-on turns it into options and runs its own start checks, and nothing
|
||||
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||||
data is set as text, never as HTML.
|
||||
P4: the Setup tab's wizard is the same page model, drawn one step at a
|
||||
time from each field's `wizard` step, and saved through the same routes. */
|
||||
(function () {
|
||||
const ROOT = 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 view = null; // "settings" or "setup": the tab drawing V
|
||||
let STEPS = {}; // wizard step -> refusals that block it
|
||||
let uid = 0;
|
||||
const ROWS = [];
|
||||
const OPEN = new Set();
|
||||
const UNLOCKED = new Set();
|
||||
|
||||
/* ---------------- the page model ---------------- */
|
||||
const sub = (f) => f.key.slice(LIST.key.length + 1);
|
||||
function valueIn(model, f, card) {
|
||||
if (card == null) return model[f.key];
|
||||
const c = (model[LIST.key] || [])[card];
|
||||
return c ? c[sub(f)] : undefined;
|
||||
}
|
||||
const get = (f, card) => valueIn(V, f, card);
|
||||
function visible(f) {
|
||||
return (f.show || []).every(([name, op, want]) => {
|
||||
const have = V[name];
|
||||
if (op === "==") return same(have, want);
|
||||
if (op === "!=") return !same(have, want);
|
||||
if (op === "in") return want.some((x) => same(x, have));
|
||||
if (have == null || have === "" || isNaN(+have)) return false;
|
||||
return op === ">" ? +have > want : +have < want;
|
||||
});
|
||||
}
|
||||
function put(f, card, v, redraw) {
|
||||
if (card == null) V[f.key] = v;
|
||||
else V[LIST.key][card][sub(f)] = v;
|
||||
if (f.mirror) {
|
||||
const m = findField(f.mirror);
|
||||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||||
}
|
||||
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||||
if (redraw || f.fills) redrawView(); else paint();
|
||||
queueValidate();
|
||||
}
|
||||
function findField(key) {
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||||
if (f.key === key) return f;
|
||||
for (const s of f.fields || []) if (s.key === key) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------- numbers: stored units <-> shown units ---------------- */
|
||||
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
|
||||
function shown(f, v) {
|
||||
if (v == null || v === "") return "";
|
||||
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
|
||||
}
|
||||
function stored(f, x) {
|
||||
const st = f.step || (f.type === "integer" ? 1 : 0);
|
||||
let v = x * (f.scale || 1);
|
||||
if (st) v = Math.round(v / st) * st;
|
||||
if (f.min != null) v = Math.max(f.min, v);
|
||||
if (f.max != null) v = Math.min(f.max, v);
|
||||
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
|
||||
}
|
||||
|
||||
/* ---------------- controls ---------------- */
|
||||
// A choice the page can only keep stays offered while it is the stored value.
|
||||
function choicesOf(f, card) {
|
||||
const cur = get(f, card), was = valueIn(ORIG, f, card);
|
||||
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
|
||||
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
|
||||
return out;
|
||||
}
|
||||
function toggle(f, card, lid) {
|
||||
const on = !!get(f, card);
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
|
||||
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
|
||||
b.addEventListener("click", () => {
|
||||
const n = b.getAttribute("aria-checked") !== "true";
|
||||
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
|
||||
put(f, card, n);
|
||||
});
|
||||
return h("span", { class: "swrap" }, b, txt);
|
||||
}
|
||||
function radios(f, card, lid, cards) {
|
||||
const cur = get(f, card);
|
||||
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
|
||||
const items = choicesOf(f, card);
|
||||
const buttons = items.map(([v, label], i) => {
|
||||
const on = same(v, cur);
|
||||
const hint = cards && f.hints ? f.hints[v] : "";
|
||||
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
|
||||
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
|
||||
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
|
||||
b.addEventListener("click", () => put(f, card, v, true));
|
||||
b.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
const j = (i + d + items.length) % items.length;
|
||||
put(f, card, items[j][0], true);
|
||||
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
|
||||
if (again) again.focus();
|
||||
});
|
||||
return b;
|
||||
});
|
||||
g.dataset.rk = f.key + "|" + card;
|
||||
g.append(...buttons);
|
||||
return g;
|
||||
}
|
||||
function select(f, card, lid) {
|
||||
const cur = get(f, card);
|
||||
const s = h("select", { class: "sel", "aria-labelledby": lid });
|
||||
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
|
||||
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
|
||||
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
|
||||
const preset = f.presets && f.presets[cur];
|
||||
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
|
||||
}
|
||||
function slider(f, card, lid) {
|
||||
if (f.min == null || f.max == null) return number(f, card, lid);
|
||||
const sc = f.scale || 1;
|
||||
const st = f.step || (f.type === "integer" ? 1 : 0.01);
|
||||
const v = get(f, card);
|
||||
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
|
||||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
|
||||
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
|
||||
n.addEventListener("change", () => {
|
||||
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
|
||||
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
|
||||
});
|
||||
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
|
||||
}
|
||||
function number(f, card, lid) {
|
||||
const sc = f.scale || 1;
|
||||
const by = FILLED[f.key];
|
||||
const preset = by && by.presets[V[by.key]];
|
||||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
|
||||
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
|
||||
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
|
||||
n.addEventListener("input", () => {
|
||||
if (n.value === "") { if (f.optional) put(f, card, null); return; }
|
||||
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
|
||||
});
|
||||
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
|
||||
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
|
||||
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
|
||||
}
|
||||
function text(f, card, lid) {
|
||||
const v = get(f, card);
|
||||
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
|
||||
t.addEventListener("input", () => put(f, card, t.value));
|
||||
return t;
|
||||
}
|
||||
function locked(f, card, lid) {
|
||||
const box = h("div", {});
|
||||
const id = f.key + "#" + card;
|
||||
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
|
||||
if (UNLOCKED.has(id)) { open(); return box; }
|
||||
const v = get(f, card);
|
||||
box.append(h("div", { class: "row" },
|
||||
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
|
||||
h("span", { class: "chip" }, W.locked),
|
||||
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
|
||||
return box;
|
||||
}
|
||||
|
||||
/* entity picker: search, the matching kind first, the live reading */
|
||||
const ENT = {};
|
||||
function entities(kind) {
|
||||
const now = Date.now();
|
||||
const e = ENT[kind];
|
||||
if (e && now - e.at < 30000) return e.p;
|
||||
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
|
||||
.catch(() => ({ available: false, entities: [] }));
|
||||
ENT[kind] = { at: now, p };
|
||||
return p;
|
||||
}
|
||||
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
|
||||
function picker(f, lid, value, pick) {
|
||||
let data = null, all = false;
|
||||
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
|
||||
const pop = h("div", { class: "ent-pop", hidden: true });
|
||||
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
|
||||
const note = h("div", { class: "tiny muted ent-note" });
|
||||
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
|
||||
const allBox = h("label", { class: "tiny muted ent-all" },
|
||||
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
|
||||
function drawBtn() {
|
||||
const id = value();
|
||||
const e = data && data.entities.find((x) => x.entity_id === id);
|
||||
btn.replaceChildren();
|
||||
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
|
||||
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
|
||||
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
|
||||
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
|
||||
}
|
||||
function opt(id, name, val) {
|
||||
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
|
||||
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
|
||||
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
|
||||
}
|
||||
function fill() {
|
||||
const typed = q.value.trim();
|
||||
const t = typed.toLowerCase();
|
||||
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
|
||||
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
|
||||
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
|
||||
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
|
||||
note.textContent = data && data.text ? data.text : "";
|
||||
note.hidden = !note.textContent;
|
||||
}
|
||||
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
|
||||
btn.addEventListener("click", () => {
|
||||
if (!pop.hidden) { close(); return; }
|
||||
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
|
||||
setTimeout(() => q.focus(), 0);
|
||||
});
|
||||
q.addEventListener("input", fill);
|
||||
pop.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const opts = [...list.querySelectorAll(".ent-opt")];
|
||||
const i = opts.indexOf(document.activeElement);
|
||||
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
|
||||
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
|
||||
});
|
||||
pop.append(q, note, list, allBox);
|
||||
drawBtn();
|
||||
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
|
||||
return h("div", { class: "ent" }, btn, pop,
|
||||
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
|
||||
}
|
||||
function entity(f, card, lid) {
|
||||
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
|
||||
}
|
||||
function entityList(f, card, lid) {
|
||||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||
const box = h("div", { class: "elist" });
|
||||
arr().forEach((_id, i) => {
|
||||
box.append(h("div", { class: "erow" },
|
||||
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
|
||||
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
|
||||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
|
||||
});
|
||||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
|
||||
return box;
|
||||
}
|
||||
function solarList(f, card, lid) {
|
||||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||
const box = h("div", { class: "elist" });
|
||||
arr().forEach((row, i) => {
|
||||
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
|
||||
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
|
||||
box.append(h("div", { class: "erow" },
|
||||
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
|
||||
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
|
||||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
|
||||
});
|
||||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
|
||||
return box;
|
||||
}
|
||||
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 (view === "setup") return ""; // the wizard shows no keys
|
||||
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||||
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||
}
|
||||
function fieldRow(f, card, gid) {
|
||||
if (f.control === "inverter_list") return cardList(f, gid);
|
||||
const lid = "sf" + (++uid);
|
||||
const keys = 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)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||||
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
|
||||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||||
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
|
||||
put(f, null, V[f.key], true);
|
||||
} }, W.remove) : null));
|
||||
for (const s of f.fields) box.append(fieldRow(s, i, gid));
|
||||
wrap.append(box);
|
||||
});
|
||||
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
|
||||
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
|
||||
put(f, null, V[f.key], true);
|
||||
} }, "+ " + W.add_inverter));
|
||||
}
|
||||
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||||
return wrap;
|
||||
}
|
||||
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
|
||||
function render() {
|
||||
const host = $("#groups");
|
||||
const y = window.scrollY;
|
||||
host.replaceChildren();
|
||||
ROWS.length = 0;
|
||||
for (const g of M.layout.groups) {
|
||||
const d = h("details", { class: "grp", "data-g": g.id },
|
||||
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
|
||||
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
|
||||
d.open = OPEN.has(g.id);
|
||||
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
|
||||
const body = h("div", { class: "body" });
|
||||
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
|
||||
for (const p of g.parts) {
|
||||
const pid = g.id + "." + p.id;
|
||||
let box;
|
||||
if (p.collapsed) {
|
||||
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
|
||||
box.open = OPEN.has(pid);
|
||||
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
|
||||
} else {
|
||||
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
|
||||
}
|
||||
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
|
||||
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
|
||||
body.append(box);
|
||||
}
|
||||
if (g.id === "maintenance") {
|
||||
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
|
||||
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
|
||||
h("div", { class: "row" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
|
||||
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
|
||||
}
|
||||
d.append(body);
|
||||
host.append(d);
|
||||
}
|
||||
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 = view === "setup" ? "" : $("#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 = !(view === "settings" && n);
|
||||
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||
}
|
||||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||
|
||||
/* ---------------- talking to the add-on ---------------- */
|
||||
let vtimer = null, vseq = 0;
|
||||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||||
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
|
||||
// or null when it failed or a newer check overtook it.
|
||||
async function validate(extra) {
|
||||
const seq = ++vseq;
|
||||
try {
|
||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
|
||||
const b = await r.json();
|
||||
if (seq !== vseq) return null;
|
||||
MSG = b.messages || [];
|
||||
DEVICE = b.device_note || "";
|
||||
STEPS = b.steps || {};
|
||||
paint();
|
||||
return b;
|
||||
} catch (e) { return null; /* the next change tries again */ }
|
||||
}
|
||||
function banner(level, title, sub, button, onClick) {
|
||||
const host = $("#set-banner");
|
||||
if (!title) { host.replaceChildren(); return; }
|
||||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||
}
|
||||
function restart() {
|
||||
ask(W.dialog, async () => {
|
||||
try {
|
||||
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "restart" }) });
|
||||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||
toast(W.toast.restarting.description);
|
||||
});
|
||||
}
|
||||
async function load(after) {
|
||||
let m;
|
||||
try {
|
||||
const r = await fetch("config/model?lang=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();
|
||||
if (view === "setup") drawStep(); else 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 });
|
||||
}
|
||||
// The one save path, for the Installer view and the wizard. After a stale
|
||||
// refusal the page holds the new token, so a second press saves. `base` is
|
||||
// the model the page opened: only what changed since then is written, onto
|
||||
// what is stored now (UX-15 F2).
|
||||
async function postSave(extra) {
|
||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP }, extra || {})) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||||
return { r, b };
|
||||
}
|
||||
// The settings another surface changed (not this page's own edits).
|
||||
function staleText(b) {
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
|
||||
}
|
||||
async function save() {
|
||||
const btn = $("#sb-save");
|
||||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||
try {
|
||||
clearTimeout(vtimer);
|
||||
await validate();
|
||||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
const { r, b } = await postSave();
|
||||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||
if (r.status === 409 && b.kind === "stale") {
|
||||
banner("bad", W.stale.name, staleText(b));
|
||||
return;
|
||||
}
|
||||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||||
toast(W.toast.failed);
|
||||
} catch (e) { toast(W.toast.failed); }
|
||||
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
|
||||
}
|
||||
$("#sb-save").addEventListener("click", save);
|
||||
// Discard reloads what is stored: after a stale save ORIG is no longer it.
|
||||
$("#sb-discard").addEventListener("click", async () => {
|
||||
await load(); toast(W.toast.discarded);
|
||||
});
|
||||
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
||||
|
||||
/* ---------------- the setup wizard (P4) ---------------- */
|
||||
const WS = W.setup;
|
||||
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
|
||||
function redrawView() { if (view === "setup") drawStep(); else render(); }
|
||||
function allFields() {
|
||||
const out = [];
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
|
||||
return out;
|
||||
}
|
||||
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
|
||||
function watts(w, signed) {
|
||||
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return (signed && w < 0 ? "-" : "") + n + " W";
|
||||
}
|
||||
function line(tier, text) {
|
||||
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
|
||||
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
|
||||
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
|
||||
}
|
||||
|
||||
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
|
||||
// uipage.kettle() (test_setup.py grades that one) on the page.
|
||||
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
|
||||
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
|
||||
function kettleOutcome(src, before, after) {
|
||||
const d = after - before;
|
||||
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
|
||||
const o = src[d > 0 ? "up" : "down"];
|
||||
return { say: o.say, sets: o.sets, d };
|
||||
}
|
||||
function kettleOk() {
|
||||
const src = kettleSource();
|
||||
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
|
||||
}
|
||||
async function readMeter(src) {
|
||||
let total = 0;
|
||||
for (const [key, sign] of src.read) {
|
||||
if (!V[key]) return null;
|
||||
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
|
||||
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
|
||||
if (!r || r.watts == null) return null;
|
||||
total += sign * r.watts;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function kettleBox() {
|
||||
const src = kettleSource();
|
||||
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
|
||||
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
|
||||
const sig = kettleSig(src);
|
||||
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
|
||||
const k = WZ.kettle || { sig };
|
||||
box.append(h("div", { class: "help" }, WS.kettle.description));
|
||||
// A skip after the reading went down still says it went down (UX-15).
|
||||
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
|
||||
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
|
||||
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
|
||||
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
|
||||
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
|
||||
const busy = (b) => { b.disabled = true; };
|
||||
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
|
||||
read.addEventListener("click", async () => {
|
||||
busy(read);
|
||||
const sig = kettleSig(src); // the entity may have changed since drawing
|
||||
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
|
||||
const w = await readMeter(src);
|
||||
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
|
||||
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
|
||||
else {
|
||||
const o = kettleOutcome(src, k.before, w);
|
||||
// `wrong`: the reading went the way no option can fix. A skip after
|
||||
// that keeps saying so on the last step (review R2).
|
||||
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
|
||||
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
|
||||
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||||
}
|
||||
drawStep();
|
||||
});
|
||||
const row = h("div", { class: "row" }, read);
|
||||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
||||
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
|
||||
} }, WS.kettle.skip));
|
||||
box.append(row);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Step 6: the shadow switch, the checks, and Save and restart.
|
||||
// The shadow switch goes only on the model the wizard posts, never into V,
|
||||
// which the Installer view saves too (review R3).
|
||||
function shadowValue() {
|
||||
const s = M.setup.shadow;
|
||||
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
|
||||
}
|
||||
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
|
||||
function shadowRow() {
|
||||
const lid = "wzs";
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
||||
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
|
||||
h("div", { class: "ctl" }, b));
|
||||
}
|
||||
async function runChecks() {
|
||||
const list = $("#wz-checks");
|
||||
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate({ setup: true, model: posted() });
|
||||
WZ.checked = b;
|
||||
if (!list || !list.isConnected) return b;
|
||||
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
|
||||
const items = [], seen = new Set();
|
||||
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
|
||||
const bad = (b.messages || []).filter((m) => m.tier === "bad");
|
||||
if (!bad.length) add("ok", WS.check.pass.description);
|
||||
for (const m of bad) add("bad", m.text);
|
||||
for (const m of b.setup || []) add(m.tier, m.text);
|
||||
if (b.device_note) add("info", b.device_note);
|
||||
const src = kettleSource();
|
||||
if (!src) add("info", WS.kettle.self.description);
|
||||
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
|
||||
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||||
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
|
||||
WZ.blocked = items.some(([t]) => t === "bad");
|
||||
return b;
|
||||
}
|
||||
function finalBox() {
|
||||
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
|
||||
setTimeout(runChecks, 0);
|
||||
return [h("div", { class: "subhead" }, WS.check.name),
|
||||
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
|
||||
shadowRow()];
|
||||
}
|
||||
function wzBanner(level, title, sub, button, onClick) {
|
||||
const host = $("#wz-banner");
|
||||
if (!title) { host.replaceChildren(); return; }
|
||||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||
}
|
||||
async function finish() {
|
||||
const b = await runChecks();
|
||||
if (!b) { toast(WS.check.failed.description); return; }
|
||||
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
||||
const shadow = shadowValue() === M.setup.shadow.on;
|
||||
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
|
||||
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
|
||||
ask(dlg, async () => {
|
||||
wzBanner();
|
||||
let res;
|
||||
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
|
||||
const { r, b: body } = res;
|
||||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||||
if (r.status === 422) {
|
||||
MSG = body.messages || []; STEPS = body.steps || {};
|
||||
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
|
||||
if (first) WZ.step = first;
|
||||
drawStep(); toast(W.toast.blocked.description); firstBad();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { toast(W.toast.failed); return; }
|
||||
let restarted = true;
|
||||
try {
|
||||
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "restart" }) });
|
||||
restarted = rr.ok;
|
||||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
|
||||
drawStep();
|
||||
if (!restarted) await load("saved"); // still running: read what is stored
|
||||
});
|
||||
}
|
||||
function drawDone() {
|
||||
const w = WS[WZ.done];
|
||||
$("#wz-count").textContent = "";
|
||||
$("#wz-steps").replaceChildren();
|
||||
$("#wz-desc").textContent = "";
|
||||
$("#wz-nav").hidden = true;
|
||||
ROWS.length = 0;
|
||||
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
|
||||
else wzBanner("ok", w.name, w.description);
|
||||
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
|
||||
}
|
||||
function drawStep() {
|
||||
if (!M) return;
|
||||
if (WZ.done) { drawDone(); return; }
|
||||
const S = M.setup, n = WZ.step, words = WS.steps[n];
|
||||
$("#wz-nav").hidden = false;
|
||||
$("#wz-title").textContent = words.name;
|
||||
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
|
||||
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
|
||||
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
|
||||
$("#wz-desc").textContent = words.description;
|
||||
ROWS.length = 0;
|
||||
const body = [];
|
||||
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
|
||||
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
|
||||
if (n === 1) body.push(kettleBox());
|
||||
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
|
||||
if (n === S.final) body.push(...finalBox());
|
||||
$("#wz-body").replaceChildren(...body);
|
||||
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
|
||||
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
|
||||
paint();
|
||||
}
|
||||
$("#wz-back").addEventListener("click", () => {
|
||||
if (WZ.step === 1) { location.hash = "#home"; return; }
|
||||
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
|
||||
});
|
||||
$("#wz-next").addEventListener("click", async () => {
|
||||
if (!M) return;
|
||||
const btn = $("#wz-next");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const n = WZ.step;
|
||||
if (n === M.setup.final) { await finish(); return; }
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate();
|
||||
if (!b) { toast(W.toast.failed); return; }
|
||||
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
|
||||
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.addEventListener("ux13:tab", (e) => {
|
||||
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
|
||||
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
|
||||
});
|
||||
// app.js chose the first tab before this file ran.
|
||||
const sel = document.querySelector(".tab[aria-selected=true]");
|
||||
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
|
||||
// First run: opened with no route, on a site the add-on says is new
|
||||
// (uipage.setup_needed), the page goes to Setup by itself.
|
||||
else if (window.UX13.first) {
|
||||
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
|
||||
}
|
||||
})();
|
||||
@@ -581,10 +581,16 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
return rep
|
||||
|
||||
|
||||
STATE_PATH = "/data/state.json"
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str = "/data/state.json"):
|
||||
self.path = path
|
||||
def __init__(self, path: str | None = None):
|
||||
self.path = path or STATE_PATH
|
||||
self.data = dict(DEFAULTS)
|
||||
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
||||
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
||||
self.fresh = False
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -598,6 +604,7 @@ class Store:
|
||||
self.data.get("phase"), self.data.get("last_completed"),
|
||||
self.data.get("sign_tripped"))
|
||||
except FileNotFoundError:
|
||||
self.fresh = True
|
||||
_LOG.info("no saved state, starting fresh")
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
# A corrupt state file must not stop the controller: losing the
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"groups": {
|
||||
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
|
||||
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
|
||||
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
|
||||
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
|
||||
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
|
||||
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
|
||||
},
|
||||
"parts": {
|
||||
"meter.timing": {"name": "Timing"},
|
||||
"inverters.solar": {"name": "Solar"},
|
||||
"tariff.selling": {"name": "Charging and selling on price"},
|
||||
"tariff.feed": {"name": "Price feed details"},
|
||||
"safety.peak": {"name": "Peak limit"},
|
||||
"safety.start": {"name": "Starting and stopping"},
|
||||
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
|
||||
"maintenance.support": {"name": "Support"}
|
||||
},
|
||||
"fields": {
|
||||
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
|
||||
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
|
||||
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
|
||||
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
||||
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
||||
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
|
||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"}
|
||||
},
|
||||
"applies": {
|
||||
"restart": {"name": "Applies after a restart"},
|
||||
"now": {"name": "Applies right away"}
|
||||
},
|
||||
"home": {
|
||||
"app": {"name": "Battery add-on"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
|
||||
"plan": {
|
||||
"name": "Today's plan",
|
||||
"description": "Point at a bar or a block for details, or tap it on a phone.",
|
||||
"cheap": "Low price",
|
||||
"dear": "High price",
|
||||
"estimate": "Estimate",
|
||||
"grid": "Charges from the grid",
|
||||
"give": "Gives power",
|
||||
"cycle": "Monthly battery cycle",
|
||||
"peak": "Peak limit",
|
||||
"house": "Follows the house",
|
||||
"now": "now",
|
||||
"time_to": "to",
|
||||
"per_kwh": "per kWh",
|
||||
"cheap_tip": "low for today",
|
||||
"dear_tip": "high for today",
|
||||
"estimate_tip": "estimate from yesterday",
|
||||
"none_tip": "No price known",
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words",
|
||||
"average_tip": "hour average"
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
|
||||
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
|
||||
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
|
||||
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
|
||||
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
|
||||
},
|
||||
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
||||
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
||||
"setup_only": {"description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
|
||||
"batteries": {"name": "Batteries", "description": "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"}
|
||||
},
|
||||
"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", "ok": "Passed"},
|
||||
"derive": {
|
||||
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
||||
"no_inverter": {"description": "Add at least one inverter."},
|
||||
"flat_named": {"description": "Add a second inverter before naming this one."},
|
||||
"no_source": {"description": "Choose where the dynamic prices come from."},
|
||||
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
|
||||
"no_price": {"description": "Enter a price per kWh."},
|
||||
"unknown": {"description": "The page cannot store this choice, so reload the page."}
|
||||
},
|
||||
"setup": {
|
||||
"name": "Setup",
|
||||
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
|
||||
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
|
||||
"step": "Step {n} of {total}",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"skip": "Next, or skip",
|
||||
"finish": "Save and restart",
|
||||
"not_now": "Not now",
|
||||
"steps": {
|
||||
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
|
||||
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
|
||||
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
|
||||
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
|
||||
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
|
||||
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
|
||||
},
|
||||
"modes_later": {"description": "Choosing a mode for each battery comes in a later update."},
|
||||
"kettle": {
|
||||
"name": "Check the direction",
|
||||
"description": "Read the meter, switch on a kettle, then read the meter again.",
|
||||
"read": "Read the meter",
|
||||
"again": "Read again",
|
||||
"skip": "Skip this test",
|
||||
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
|
||||
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
|
||||
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
|
||||
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
|
||||
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
|
||||
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
|
||||
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
|
||||
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
|
||||
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
|
||||
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
|
||||
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction before you start control."},
|
||||
"tested": {"description": "The meter direction was tested with a kettle."},
|
||||
"needed": {"description": "Do the kettle test first, or skip it."}
|
||||
},
|
||||
"check": {
|
||||
"name": "What the add-on checked",
|
||||
"running": {"description": "Checking with the add-on's own start checks."},
|
||||
"pass": {"description": "The add-on's start checks pass."},
|
||||
"failed": {"description": "The check did not run, so try again."},
|
||||
"again": "Check again",
|
||||
"blocked": {"description": "Fix the items marked in red, then check again."}
|
||||
},
|
||||
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
||||
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
||||
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
||||
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
|
||||
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
|
||||
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
|
||||
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
|
||||
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
|
||||
"home": "Open Home"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"""UX-13: the new page's layout, written once - screen, group, field.
|
||||
|
||||
UI code names option keys only here and in uimodel.py, which holds the
|
||||
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
|
||||
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
|
||||
schema, rules in validate.py. test_uilayout.py fails CI when an option in
|
||||
config.yaml has no place here.
|
||||
|
||||
A field's `key` is an option key, `inverters.<field>` for one inverter in the
|
||||
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
|
||||
The key is also its text key: label and help are translations/*.yaml's entry
|
||||
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
|
||||
NOW = "now" # runtime state (OPT-11's battery switches), none yet
|
||||
|
||||
# Controls are names, not string literals, so a key is never followed by a
|
||||
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
|
||||
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
|
||||
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
|
||||
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
|
||||
"number", "text", "select", "locked", "inverter_list", "solar_list")
|
||||
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
|
||||
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST)
|
||||
# What an entity picker offers first. `power_w` is a power sensor the add-on
|
||||
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
|
||||
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
|
||||
SHOW_OPS = ("==", "!=", "in", ">", "<")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Field:
|
||||
key: str
|
||||
control: str
|
||||
unit: str = ""
|
||||
scale: int = 1 # shown = stored / scale (the peak limit in kW)
|
||||
step: float = 0 # slider or number step, stored units; 0 = 1
|
||||
kind: str = "" # entity picker filter, one of ENTITY_KINDS
|
||||
show: tuple = () # ((name, op, value), ...), all must hold
|
||||
applies: str = RESTART
|
||||
simple: bool = False # also on Settings, Simple
|
||||
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
|
||||
mirror: str = "" # a choice also copied to this field, if offered
|
||||
fills: str = "" # a preset choice fills this field (uimodel)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Where the label and help live: `translations` or `ui_text`."""
|
||||
return "ui_text" if self.key.startswith("ui.") else "translations"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Part:
|
||||
id: str # "" is the group's main part
|
||||
fields: tuple
|
||||
collapsed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Group:
|
||||
id: str
|
||||
parts: tuple
|
||||
support_only: bool = False # collapsed, with a "for support" warning
|
||||
|
||||
|
||||
def _src(*values):
|
||||
return (("meter_source", "in", values),)
|
||||
|
||||
|
||||
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
|
||||
_MAINT = (("maintenance_enabled", "==", True),)
|
||||
_ONE = (("inverter_count", "==", 1),)
|
||||
_SEVERAL = (("inverter_count", ">", 1),)
|
||||
_PHASES = (("meter_phases", ">", 1),)
|
||||
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
|
||||
_LISTED = (("inverter_shape", "!=", "flat"),)
|
||||
|
||||
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
|
||||
ROSTER_FIELDS = (
|
||||
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
|
||||
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
|
||||
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
|
||||
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
|
||||
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
|
||||
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
|
||||
Field("inverters.batt_invert", TOGGLE, wizard=2),
|
||||
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
|
||||
wizard=2),
|
||||
)
|
||||
|
||||
GROUPS = (
|
||||
Group("meter", (
|
||||
Part("", (
|
||||
Field("meter_source", CHOICE, wizard=1),
|
||||
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
|
||||
wizard=1),
|
||||
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
|
||||
Field("p1_net_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_signed"), wizard=1),
|
||||
Field("p1_import_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_dsmr"), wizard=1),
|
||||
Field("p1_export_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_dsmr"), wizard=1),
|
||||
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
|
||||
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
|
||||
Field("meter_phases", SEGMENTED, wizard=1),
|
||||
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_signed") + _PHASES, wizard=1),
|
||||
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
||||
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
||||
)),
|
||||
Part("timing", (
|
||||
Field("meter_max_age_s", SLIDER, "s", step=5),
|
||||
Field("meter_poll_s", SLIDER, "s",
|
||||
show=_src("homewizard_local")),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("inverters", (
|
||||
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
|
||||
# pv.py reads each entity's own unit, so a kW sensor is fine here.
|
||||
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
||||
wizard=3),)),
|
||||
)),
|
||||
Group("tariff", (
|
||||
Part("", (
|
||||
# A contract without a feed is also the price used when a feed
|
||||
# is down, so the fallback follows it (uimodel._tariff_encode).
|
||||
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
|
||||
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
|
||||
Field("price_formula", TEXT,
|
||||
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
|
||||
wizard=4),
|
||||
Field("price_csv_path", TEXT,
|
||||
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
|
||||
wizard=4),
|
||||
Field("ui.region", SELECT, wizard=4,
|
||||
fills="price_taxes_eur_kwh"),
|
||||
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
|
||||
wizard=4),
|
||||
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
||||
show=_DYNAMIC, wizard=4),
|
||||
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
||||
wizard=4),
|
||||
Field("price_export_formula", TEXT),
|
||||
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
|
||||
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "fixed"),), wizard=4),
|
||||
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
)),
|
||||
Part("selling", (
|
||||
# OPT-11: derived from the batteries' selling switches once battery
|
||||
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
|
||||
Field("arbitrage_enabled", TOGGLE),
|
||||
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
|
||||
Field("arbitrage_soc_floor", SLIDER, "%"),
|
||||
Field("arbitrage_soc_ceiling", SLIDER, "%"),
|
||||
)),
|
||||
Part("feed", (
|
||||
Field("price_feed_url", TEXT, show=_DYNAMIC),
|
||||
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
|
||||
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
|
||||
Field("price_now_entity", ENTITY, kind="any"),
|
||||
Field("price_avg_entity", ENTITY, kind="any"),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("safety", (
|
||||
Part("peak", (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5),
|
||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
||||
Field("peak_soc_floor", LOCKED, "%"),
|
||||
)),
|
||||
Part("start", (
|
||||
Field("shadow_mode", SEGMENTED, simple=True, wizard=6),
|
||||
Field("auto_start", TOGGLE),
|
||||
Field("estop_fitted", TOGGLE),
|
||||
Field("stale_input_s", SLIDER, "s"),
|
||||
Field("meter_freeze_s", SLIDER, "s", step=30,
|
||||
show=_src("homewizard_local")),
|
||||
)),
|
||||
Part("stops", (
|
||||
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
|
||||
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
|
||||
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
|
||||
show=_SEVERAL),
|
||||
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
|
||||
show=_SEVERAL),
|
||||
Field("site_sign_disagree_w", LOCKED, "W", step=10,
|
||||
show=_SEVERAL),
|
||||
Field("site_sign_disagree_s", LOCKED, "s", step=5,
|
||||
show=_SEVERAL),
|
||||
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
|
||||
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
||||
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("maintenance", (
|
||||
Part("", (
|
||||
Field("maintenance_enabled", TOGGLE),
|
||||
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
|
||||
Field("maintenance_start_hour", SELECT, show=_MAINT),
|
||||
Field("maintenance_discharge_w", SLIDER, "W", step=100,
|
||||
show=_MAINT),
|
||||
Field("maintenance_charge_w", SLIDER, "W", step=100,
|
||||
show=_MAINT),
|
||||
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
|
||||
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
|
||||
Field("maintenance_hold_min", SLIDER, "min", step=5,
|
||||
show=_MAINT),
|
||||
)),
|
||||
Part("support", (Field("log_level", SELECT),)),
|
||||
)),
|
||||
Group("tuning", (
|
||||
Part("", (
|
||||
Field("gain", LOCKED, step=0.05),
|
||||
Field("slew_w", LOCKED, "W", step=50),
|
||||
Field("deadband_w", LOCKED, "W"),
|
||||
Field("target_grid_w", LOCKED, "W"),
|
||||
Field("step_w", LOCKED, "W"),
|
||||
Field("saturation_w", LOCKED, "W", step=10),
|
||||
Field("saturation_cycles", LOCKED),
|
||||
Field("integrator_max_w", LOCKED, "W", step=100),
|
||||
Field("heartbeat_s", LOCKED, "s"),
|
||||
Field("cycle_floor_s", LOCKED, "s"),
|
||||
)),
|
||||
), support_only=True),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
|
||||
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
|
||||
# --------------------------------------------------------------------------- #
|
||||
WIZARD_STEPS = 6
|
||||
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
|
||||
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
|
||||
|
||||
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
|
||||
# read before and after a kettle is switched on, each with the sign it adds to
|
||||
# the grid reading, and what each direction means as (outcome, fields it
|
||||
# sets). Sets None: the options cannot fix it, so the step is not passed. A
|
||||
# source not listed is read by the add-on itself, so the page cannot test it.
|
||||
KETTLE_SOURCE = "meter_source"
|
||||
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
|
||||
KETTLE = {
|
||||
"off": ((("meter_entity", 1),),
|
||||
{"up": ("ok", {"meter_invert": False}),
|
||||
"down": ("flip", {"meter_invert": True})}),
|
||||
"ha_signed": ((("p1_net_entity", 1),),
|
||||
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
|
||||
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
|
||||
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
|
||||
}
|
||||
# Step 6 starts the site in shadow mode unless the installer switches it off:
|
||||
# (field, value when on, value when off).
|
||||
SHADOW = ("shadow_mode", "on", "auto")
|
||||
|
||||
# Option keys the page never shows. uimodel.py writes each from page fields.
|
||||
DERIVED = {
|
||||
"setpoint_entity": "inverters", # one inverter, stored flat
|
||||
"soc_entity": "inverters",
|
||||
"batt_entity": "inverters",
|
||||
"batt_invert": "inverters",
|
||||
"max_w": "inverters",
|
||||
"bms_soc_floor": "inverters",
|
||||
"pv_entity": "ui.solar", # one solar row, stored flat
|
||||
"pv_entities": "ui.solar",
|
||||
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
|
||||
"static_profile": "ui.fallback", # built from the price fields
|
||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||
}
|
||||
# Option keys the page never shows and never writes: passed through as stored.
|
||||
HIDDEN: dict = {}
|
||||
|
||||
|
||||
def fields():
|
||||
"""Every field once, in page order, roster fields after `inverters`."""
|
||||
for group in GROUPS:
|
||||
for part in group.parts:
|
||||
for field in part.fields:
|
||||
yield group, part, field
|
||||
if field.control == INVERTER_LIST:
|
||||
for sub in ROSTER_FIELDS:
|
||||
yield group, part, sub
|
||||
|
||||
|
||||
def field(key: str) -> Field | None:
|
||||
return next((f for _g, _p, f in fields() if f.key == key), None)
|
||||
|
||||
|
||||
def visible(fld: Field, model: dict) -> bool:
|
||||
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
|
||||
for name, op, value in fld.show:
|
||||
have = model.get(name)
|
||||
if op == "==" and have != value:
|
||||
return False
|
||||
if op == "!=" and have == value:
|
||||
return False
|
||||
if op == "in" and have not in value:
|
||||
return False
|
||||
try:
|
||||
if op == ">" and not float(have) > value:
|
||||
return False
|
||||
if op == "<" and not float(have) < value:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def range_of(key: str, schema_ui: list):
|
||||
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
|
||||
|
||||
Never typed here: config.yaml is the one place a range is written. A range
|
||||
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
|
||||
path = key.split(".")
|
||||
nodes = schema_ui or []
|
||||
for step in path[:-1]:
|
||||
parent = next((n for n in nodes if n.get("name") == step), None)
|
||||
nodes = (parent or {}).get("schema") or []
|
||||
node = next((n for n in nodes if n.get("name") == path[-1]), None)
|
||||
if not node or "lengthMin" not in node or "lengthMax" not in node:
|
||||
return None
|
||||
return node["lengthMin"], node["lengthMax"]
|
||||
@@ -0,0 +1,369 @@
|
||||
"""UX-13: stored options <-> the page model, for the keys the page derives.
|
||||
|
||||
`options_to_ui()` turns the stored blob into what the page edits: always a list
|
||||
of inverter cards, solar rows with a "reads negative" toggle, a contract and
|
||||
simple prices instead of `static_profile` JSON, and a region preset instead of
|
||||
the levies figure. `ui_to_options()` turns it back, so the validator and the
|
||||
Supervisor always see the real blob.
|
||||
|
||||
The rule that makes the round trip exact: the model carries the stored values
|
||||
of the derived keys (`keep`). A derivation whose page fields still read what
|
||||
`keep` decodes to writes `keep` back untouched, and only an edited one is
|
||||
re-encoded. So a blob the page cannot express (a `static_profile` with odd
|
||||
windows, a malformed roster) survives a save of some other field unchanged.
|
||||
See DECISIONS/UX-13-one-layout-table.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
|
||||
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
|
||||
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
|
||||
"REGION_PRESETS",
|
||||
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
||||
|
||||
|
||||
class DeriveError(ValueError):
|
||||
"""The page model asks for something no options blob can say. `code` names
|
||||
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
|
||||
page field it is shown beside."""
|
||||
|
||||
def __init__(self, text, code="", field=""):
|
||||
super().__init__(text)
|
||||
self.code, self.field = code, field
|
||||
self.keys = () # the option keys the failed derivation writes
|
||||
|
||||
|
||||
# The page model's inverter count, which show conditions read, and what adding
|
||||
# a card sets: a second inverter turns the one flat inverter into a list.
|
||||
COUNT = "inverter_count"
|
||||
GROWN = {"inverter_shape": "list"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
|
||||
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
|
||||
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
|
||||
# residential card, Flanders, September 2026, VAT included). Wallonia and
|
||||
# Brussels have a day and a night band (config.yaml) and join with sourced
|
||||
# figures; until then those sites choose "own".
|
||||
# --------------------------------------------------------------------------- #
|
||||
REGION_PRESETS = {
|
||||
"flanders": {
|
||||
"year": 2026,
|
||||
"taxes_eur_kwh": round(sum(v for _n, v in
|
||||
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
|
||||
"taxes_profile": "",
|
||||
},
|
||||
}
|
||||
OWN = "own"
|
||||
|
||||
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
|
||||
"max_w", "bms_soc_floor")
|
||||
_TOL = 1e-9
|
||||
|
||||
|
||||
def _blank(v) -> bool:
|
||||
return v is None or (isinstance(v, str) and not v.strip())
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return None if _blank(v) or isinstance(v, bool) else float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
|
||||
def _roster_decode(o):
|
||||
entries = o.get("inverters")
|
||||
if entries is None or entries == "" or entries == []:
|
||||
card = {k: o.get(k) for k in _ROSTER_FLAT}
|
||||
card.update(name="", capacity_kwh=None)
|
||||
return {"inverter_shape": "flat", "inverters": [card]}
|
||||
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
|
||||
return {"inverter_shape": "list",
|
||||
"inverters": [dict(e) for e in entries]}
|
||||
# Not something a card can show: edited in the Configuration tab only.
|
||||
return {"inverter_shape": "raw", "inverters": None}
|
||||
|
||||
|
||||
def _roster_encode(m, keep):
|
||||
out = {}
|
||||
shape, cards = m.get("inverter_shape"), m.get("inverters")
|
||||
if shape == "raw":
|
||||
raise DeriveError("the inverter list is edited in the Configuration tab",
|
||||
"raw_roster", "inverters")
|
||||
if not cards:
|
||||
raise DeriveError("at least one inverter is needed", "no_inverter",
|
||||
"inverters")
|
||||
if shape == "flat" and len(cards) == 1:
|
||||
card = cards[0]
|
||||
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
|
||||
raise DeriveError("one inverter stored flat has no name or "
|
||||
"capacity", "flat_named", "inverters")
|
||||
for k in _ROSTER_FLAT:
|
||||
if card.get(k) is not None or k in keep:
|
||||
out[k] = card.get(k)
|
||||
out["inverters"] = []
|
||||
return out
|
||||
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
|
||||
for c in cards]
|
||||
# The flat keys beside a list are not read: they stay as stored.
|
||||
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
|
||||
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
|
||||
# CFG-04 rule 9e: a flat power request entity beside a list is refused,
|
||||
# so a list that replaces the flat inverter clears it.
|
||||
out["setpoint_entity"] = ""
|
||||
return out
|
||||
|
||||
|
||||
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
|
||||
def _strs(v):
|
||||
return [str(x).strip() for x in (v if isinstance(v, list) else [])
|
||||
if not _blank(x)]
|
||||
|
||||
|
||||
def _solar_rows(o):
|
||||
listed = _strs(o.get("pv_entities"))
|
||||
if listed:
|
||||
return listed, True
|
||||
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
|
||||
return ([one] if one else []), False
|
||||
|
||||
|
||||
def _solar_decode(o):
|
||||
rows, _listed = _solar_rows(o)
|
||||
flipped = set(_strs(o.get("pv_invert_entities")))
|
||||
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
|
||||
for e in rows]}
|
||||
|
||||
|
||||
def _solar_encode(m, keep):
|
||||
out = {}
|
||||
rows = [r for r in (m.get("ui.solar") or [])
|
||||
if not _blank(r.get("entity"))]
|
||||
entities = [str(r["entity"]).strip() for r in rows]
|
||||
old_rows, was_list = _solar_rows(keep)
|
||||
if len(entities) <= 1 and not was_list:
|
||||
out["pv_entity"] = entities[0] if entities else ""
|
||||
if "pv_entities" in keep:
|
||||
out["pv_entities"] = []
|
||||
else:
|
||||
out["pv_entities"] = entities
|
||||
if "pv_entity" in keep:
|
||||
out["pv_entity"] = "" # TEL-12: the list replaces it
|
||||
# Flips of entities that were never a row stay as stored (pv.stray_inverts
|
||||
# reports them). Flips of removed rows go with the row.
|
||||
extras = [x for x in _strs(keep.get("pv_invert_entities"))
|
||||
if x not in old_rows and x not in entities]
|
||||
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
|
||||
if flips or "pv_invert_entities" in keep:
|
||||
out["pv_invert_entities"] = flips
|
||||
return out
|
||||
|
||||
|
||||
# --- 3. contract and static_profile ----------------------------------------- #
|
||||
def _fixed_json(price) -> str:
|
||||
return json.dumps({"periods": [
|
||||
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
|
||||
"eur_per_kwh": price}]})
|
||||
|
||||
|
||||
def _daynight_json(day, night) -> str:
|
||||
return json.dumps({"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": day},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": night},
|
||||
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||||
"eur_per_kwh": night}]})
|
||||
|
||||
|
||||
def _shape(text):
|
||||
"""The periods of a profile without their names, for comparing, or None."""
|
||||
try:
|
||||
prof = parse_static_profile(text)
|
||||
except ProfileInvalid:
|
||||
return None
|
||||
if prof is None:
|
||||
return ()
|
||||
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
|
||||
p.eur_per_kwh) for p in prof.periods))
|
||||
|
||||
|
||||
def _profile_decode(raw):
|
||||
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
|
||||
if isinstance(raw, str) and _blank(raw):
|
||||
return "none", None, None, None
|
||||
if not isinstance(raw, str):
|
||||
return "custom", None, None, None
|
||||
got = _shape(raw)
|
||||
if not got:
|
||||
return ("none" if got == () else "custom"), None, None, None
|
||||
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
|
||||
return "fixed", got[0][3], None, None
|
||||
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
|
||||
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
|
||||
if (day is not None and night is not None
|
||||
and got == _shape(_daynight_json(day, night))):
|
||||
return "daynight", None, day, night
|
||||
return "custom", None, None, None
|
||||
|
||||
|
||||
def _tariff_decode(o):
|
||||
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
|
||||
provider = o.get("price_provider")
|
||||
dynamic = not _blank(provider) and provider != "off"
|
||||
return {"ui.contract": "dynamic" if dynamic else kind,
|
||||
"price_provider": provider, "ui.fallback": kind,
|
||||
"ui.fixed_price": fixed, "ui.day_price": day,
|
||||
"ui.night_price": night}
|
||||
|
||||
|
||||
def _tariff_encode(m, keep):
|
||||
out = {}
|
||||
contract = m.get("ui.contract")
|
||||
if contract == "dynamic":
|
||||
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
|
||||
raise DeriveError("a dynamic contract needs a price source",
|
||||
"no_source", "price_provider")
|
||||
out["price_provider"] = m["price_provider"]
|
||||
kind = m.get("ui.fallback")
|
||||
else:
|
||||
out["price_provider"] = "off"
|
||||
kind = contract
|
||||
if kind == "none":
|
||||
out["static_profile"] = ""
|
||||
elif kind == "fixed":
|
||||
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
|
||||
elif kind == "daynight":
|
||||
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
|
||||
_price_of(m, "ui.night_price"))
|
||||
elif kind == "custom":
|
||||
if "static_profile" not in keep:
|
||||
raise DeriveError("odd tariff windows are edited in the "
|
||||
"Configuration tab", "custom_profile",
|
||||
"ui.contract")
|
||||
out["static_profile"] = keep["static_profile"]
|
||||
else:
|
||||
raise DeriveError(f"unknown contract {kind!r}", "unknown",
|
||||
"ui.contract")
|
||||
return out
|
||||
|
||||
|
||||
def _price_of(m, name):
|
||||
value = _num(m.get(name))
|
||||
if value is None:
|
||||
raise DeriveError(f"{name} needs a price", "no_price", name)
|
||||
return value
|
||||
|
||||
|
||||
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
|
||||
def _region_decode(o):
|
||||
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
|
||||
region = OWN
|
||||
for rid, p in REGION_PRESETS.items():
|
||||
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
|
||||
if same and str(profile or "").strip() == p["taxes_profile"]:
|
||||
region = rid
|
||||
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
|
||||
|
||||
|
||||
def _region_encode(m, keep):
|
||||
region = m.get("ui.region")
|
||||
if region == OWN:
|
||||
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
|
||||
if "price_taxes_profile" in keep: # "own" keeps the stored table
|
||||
out["price_taxes_profile"] = keep["price_taxes_profile"]
|
||||
return out
|
||||
if region not in REGION_PRESETS:
|
||||
raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region")
|
||||
p = REGION_PRESETS[region]
|
||||
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
|
||||
if p["taxes_profile"] or "price_taxes_profile" in keep:
|
||||
out["price_taxes_profile"] = p["taxes_profile"]
|
||||
return out
|
||||
|
||||
|
||||
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
|
||||
def arbitrage_from_batteries(m):
|
||||
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
|
||||
battery may sell. Battery modes are not built yet, so this returns None and
|
||||
the toggle is stored as the page has it: no behaviour change."""
|
||||
return None
|
||||
|
||||
|
||||
def _arbitrage_decode(o):
|
||||
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
|
||||
|
||||
|
||||
def _arbitrage_encode(m, keep):
|
||||
derived = arbitrage_from_batteries(m)
|
||||
value = m.get("arbitrage_enabled") if derived is None else derived
|
||||
return {} if value is None and "arbitrage_enabled" not in keep \
|
||||
else {"arbitrage_enabled": value}
|
||||
|
||||
|
||||
# (option keys it writes, decode, encode). Each is independent of the others.
|
||||
DERIVATIONS = (
|
||||
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
|
||||
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
|
||||
_solar_encode),
|
||||
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
|
||||
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
|
||||
_region_encode),
|
||||
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
|
||||
)
|
||||
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
|
||||
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
|
||||
|
||||
|
||||
def options_to_ui(options: dict) -> dict:
|
||||
"""The page model for a stored blob. Pure: reads nothing else."""
|
||||
model = {k: copy.deepcopy(v) for k, v in options.items()
|
||||
if k not in _OWNED}
|
||||
for _keys, decode, _encode in DERIVATIONS:
|
||||
model.update(decode(options))
|
||||
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
|
||||
if k in options}
|
||||
cards = model.get("inverters")
|
||||
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
|
||||
preset = REGION_PRESETS.get(model["ui.region"])
|
||||
model["region_year"] = preset["year"] if preset else None
|
||||
return model
|
||||
|
||||
|
||||
def ui_to_options(model: dict) -> dict:
|
||||
"""The options blob for a page model. Raises DeriveError on a model no blob
|
||||
can express, so the page says so instead of saving something else."""
|
||||
out, errors = ui_to_options_partial(model)
|
||||
if errors:
|
||||
raise errors[0]
|
||||
return out
|
||||
|
||||
|
||||
def ui_to_options_partial(model: dict):
|
||||
"""(blob, [DeriveError]): every derivation that can be made, and the
|
||||
stored values (`keep`) for each one that cannot. For the checks only, so a
|
||||
step can be blocked by its own field and the rest still checked (UX-15
|
||||
F1). Never saved: `ui_to_options()` raises instead."""
|
||||
keep = model.get("keep") or {}
|
||||
out = {k: copy.deepcopy(v) for k, v in model.items()
|
||||
if k not in _MODEL_ONLY and k not in _OWNED
|
||||
and not k.startswith("ui.")}
|
||||
errors = []
|
||||
for keys, decode, encode in DERIVATIONS:
|
||||
was = decode(keep)
|
||||
if all(model.get(name) == value for name, value in was.items()):
|
||||
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
|
||||
continue
|
||||
try:
|
||||
out.update(encode(model, keep))
|
||||
except DeriveError as err:
|
||||
err.keys = keys
|
||||
errors.append(err)
|
||||
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
|
||||
return out, errors
|
||||
@@ -0,0 +1,397 @@
|
||||
"""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
|
||||
from .validate import power_request_entities
|
||||
|
||||
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,
|
||||
"wizard": fld.wizard,
|
||||
"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)."""
|
||||
return _settle(U.ui_to_options(model), stored)
|
||||
|
||||
|
||||
def to_options_partial(model: dict, stored: dict | None = None):
|
||||
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
|
||||
settled like `to_options`. Never saved (UX-15 F1)."""
|
||||
out, errors = U.ui_to_options_partial(model)
|
||||
return _settle(out, stored), errors
|
||||
|
||||
|
||||
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
|
||||
"""The blob to save: `stored`, with the keys whose value in `mine` (this
|
||||
page's model) differs from `base` (the model it opened) set to `mine`'s.
|
||||
A key this page never edited keeps what is stored now, even when another
|
||||
surface changed it meanwhile (UX-13 P4 test, F2)."""
|
||||
edits = {k: v for k, v in mine.items()
|
||||
if k not in base or not _same(v, base[k])}
|
||||
gone = {k for k in base if k not in mine}
|
||||
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
|
||||
if k not in gone}
|
||||
out.update({k: copy.deepcopy(v) for k, v in edits.items()
|
||||
if k not in out})
|
||||
return out
|
||||
|
||||
|
||||
def _settle(out: dict, stored: dict | None) -> dict:
|
||||
stored = stored or {}
|
||||
for k, v in out.items():
|
||||
if k in stored and _same(v, stored[k]):
|
||||
out[k] = copy.deepcopy(stored[k])
|
||||
return ({k: out[k] for k in stored if k in out}
|
||||
| {k: v for k, v in out.items() if k not in stored})
|
||||
|
||||
|
||||
def derive_message(err: U.DeriveError) -> 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}
|
||||
|
||||
|
||||
def derive_messages(errors, problems, notes, model, keys):
|
||||
"""The page's messages when part of the model could not become options:
|
||||
each conversion's own sentence beside its field, then the checks of the
|
||||
rest. A refusal or note about a key the failed conversion writes is left
|
||||
out, because it judges the stored value, not what the page shows."""
|
||||
failed = {k for err in errors for k in err.keys}
|
||||
by_key, unanchored = problems_by_field(problems, keys)
|
||||
kept = [t for k, texts in by_key.items() if k not in failed
|
||||
for t in texts] + list(unanchored)
|
||||
kept = [p for p in problems if p in kept]
|
||||
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
||||
return ([derive_message(err) for err in errors]
|
||||
+ anchors(kept, notes, model, keys))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Where a sentence goes on the page
|
||||
# --------------------------------------------------------------------------- #
|
||||
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
|
||||
_SUB = re.compile(r"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
|
||||
|
||||
|
||||
def _mirror_of(key):
|
||||
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
|
||||
|
||||
|
||||
def targets(key: str, text: str, model: dict) -> list:
|
||||
"""[(page field, card)] for a sentence that names option `key`. `card` is
|
||||
the inverter card's index, or None."""
|
||||
roster = _roster()
|
||||
cards = model.get(roster) if isinstance(model.get(roster), list) else []
|
||||
names = [c.get("name") for c in cards if isinstance(c, dict)]
|
||||
# messages.setting(): “Inverter rating” for inverter-b (inverters.max_w),
|
||||
# or a roster rule's own `inverters[1] (inverter-b)`.
|
||||
card = next((i for i, n in enumerate(names)
|
||||
if n and f"for {n} ({roster}." in text), None)
|
||||
index = re.search(re.escape(roster) + r"\[(\d+)\]", text)
|
||||
if card is None and index and int(index.group(1)) < len(cards):
|
||||
card = int(index.group(1))
|
||||
if key == roster:
|
||||
subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card)
|
||||
for r, i, s in _SUB.findall(text) if r == roster]
|
||||
subs = [x for x in dict.fromkeys(subs) if L.field(x[0])]
|
||||
return subs or [(roster, card)]
|
||||
if key in L.DERIVED:
|
||||
how = L.DERIVED[key]
|
||||
if how == roster:
|
||||
return [(f"{roster}.{key}", 0 if card is None else card)]
|
||||
fld = L.field(how)
|
||||
if fld is not None and not L.visible(fld, model) and _mirror_of(how):
|
||||
return [(_mirror_of(how), None)]
|
||||
return [(how, None)]
|
||||
if L.field(key) is not None:
|
||||
return [(key, None)]
|
||||
return [(None, None)]
|
||||
|
||||
|
||||
def anchors(problems, notes, model, keys) -> list:
|
||||
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
||||
None for a sentence that names no setting on the page."""
|
||||
out, seen = [], set()
|
||||
|
||||
def add(field, card, tier, text):
|
||||
if (field, card, text) not in seen:
|
||||
seen.add((field, card, text))
|
||||
out.append({"field": field, "card": card, "tier": tier,
|
||||
"text": text})
|
||||
|
||||
by_key, unanchored = problems_by_field(problems, keys)
|
||||
for key, texts in by_key.items():
|
||||
for text in texts:
|
||||
for field, card in targets(key, text, model):
|
||||
add(field, card, "bad", text)
|
||||
for text in unanchored:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
add(field, card, tier, note.text)
|
||||
# A sentence placed on one inverter's field is not also put on the list.
|
||||
roster = _roster()
|
||||
placed = {m["text"] for m in out
|
||||
if (m["field"] or "").startswith(roster + ".")}
|
||||
return [m for m in out if not (m["field"] == roster
|
||||
and m["text"] in placed)]
|
||||
|
||||
|
||||
def fields_of(keys, model) -> list:
|
||||
"""The page fields that show these option keys (a stale save's list)."""
|
||||
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
|
||||
if f))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Store keys (app/store.py), not options. SETUP_DONE is written when the
|
||||
# wizard saves. STARTED is the one control's Start and Stop write.
|
||||
SETUP_DONE = "setup_done"
|
||||
STARTED = "auto"
|
||||
|
||||
|
||||
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
|
||||
"""Why the page opens the wizard by itself, or None.
|
||||
|
||||
"no_power_request": the stored options name no inverter to control, so
|
||||
the add-on cannot start on them. "first_start": a new install. This boot
|
||||
found no Store file (`fresh`), the wizard never saved and control was never
|
||||
started or stopped. `fresh` is what tells a new install from an old site
|
||||
that was never started, e.g. one left in shadow for weeks (TEST-23).
|
||||
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
||||
whether control is started now."""
|
||||
if not power_request_entities(stored or {}):
|
||||
return "no_power_request"
|
||||
if store_data is None or not fresh:
|
||||
return None
|
||||
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
||||
return None
|
||||
return "first_start"
|
||||
|
||||
|
||||
def setup_spec() -> dict:
|
||||
"""What the wizard needs beyond each field's step: read off the table."""
|
||||
kettle = {}
|
||||
for source, (read, outcomes) in L.KETTLE.items():
|
||||
kettle[source] = {"read": [[f, sign] for f, sign in read]}
|
||||
for way, (say, sets) in outcomes.items():
|
||||
kettle[source][way] = {"say": say, "sets": sets}
|
||||
field, on, off = L.SHADOW
|
||||
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
|
||||
"optional": list(L.OPTIONAL_STEPS),
|
||||
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
|
||||
"sources": kettle},
|
||||
"shadow": {"field": field, "on": on, "off": off}}
|
||||
|
||||
|
||||
def kettle(source, before_w, after_w):
|
||||
"""The kettle test's verdict, as the page reaches it (settings.js
|
||||
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
|
||||
for a source the page cannot read."""
|
||||
spec = L.KETTLE.get(source)
|
||||
if spec is None:
|
||||
return None
|
||||
change = float(after_w) - float(before_w)
|
||||
if abs(change) < L.KETTLE_MIN_W:
|
||||
return "none", None, change
|
||||
say, sets = spec[1]["up" if change > 0 else "down"]
|
||||
return say, (dict(sets) if sets is not None else None), change
|
||||
|
||||
|
||||
def step_of(field_key, model) -> int:
|
||||
"""The wizard step a sentence on this page field blocks: the field's own
|
||||
step while it is shown, else the last step, which lists everything."""
|
||||
fld = L.field(field_key) if field_key else None
|
||||
if fld is None or not fld.wizard or not L.visible(fld, model):
|
||||
return L.FINAL_STEP
|
||||
return fld.wizard
|
||||
|
||||
|
||||
def step_problems(messages, model) -> dict:
|
||||
"""{"1": n, ...}: how many refusals block each wizard step."""
|
||||
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
|
||||
for m in messages:
|
||||
if m.get("tier") == "bad":
|
||||
out[str(step_of(m.get("field"), model))] += 1
|
||||
return out
|
||||
@@ -321,8 +321,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import messages
|
||||
from . import messages, pv
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
@@ -330,7 +331,8 @@ from .custom_formula import (
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .p1 import (
|
||||
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import (
|
||||
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
||||
@@ -367,6 +369,23 @@ class _WarnOnce(logging.Filter):
|
||||
WARN_ONCE = _WarnOnce()
|
||||
_LOG.addFilter(WARN_ONCE)
|
||||
|
||||
|
||||
class Note(NamedTuple):
|
||||
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
||||
add-on starts. `keys` are the options it is about, so the page can show it
|
||||
beside them."""
|
||||
tier: str # "warn" or "info"; the wizard's checks
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
|
||||
|
||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
||||
"""Add a Note when the caller collects them (the settings page)."""
|
||||
if notes is not None:
|
||||
notes.append(Note(tier, tuple(keys), text))
|
||||
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
# threshold is a compile-time YAML substitution baked into the ESP32 image
|
||||
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
|
||||
@@ -789,7 +808,16 @@ class ConfigRefused(Exception):
|
||||
super().__init__(problems[0] if problems else "configuration refused")
|
||||
|
||||
|
||||
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
|
||||
def no_power_request_problem() -> str:
|
||||
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
|
||||
starts when this is the only refusal (app/setuponly.py)."""
|
||||
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
|
||||
"the inverter. Set it to the ESPHome number entity for the "
|
||||
"setpoint.")
|
||||
|
||||
|
||||
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
notes: list | None = None) -> list[str]:
|
||||
"""Return the list of reasons this configuration must not be started.
|
||||
|
||||
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
|
||||
@@ -799,15 +827,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
|
||||
Pure and side-effect free apart from two warnings, so it can be called from
|
||||
a test or from sim/inject.py without a Home Assistant anywhere near it.
|
||||
|
||||
UX-13: pass a list as `notes` to also get each logged warning as a `Note`
|
||||
for the settings page. The refusals returned do not depend on it.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
|
||||
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
|
||||
writers = actuator_writers(opts)
|
||||
if not writers:
|
||||
problems.append(
|
||||
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
|
||||
"inverter. Set it to the ESPHome number entity for the setpoint.")
|
||||
problems.append(no_power_request_problem())
|
||||
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
||||
# count of option keys. Read `actuator_writers()` for why the old check went
|
||||
# green on the very config this one is here to refuse.
|
||||
@@ -1127,7 +1156,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"site. Set it to 0 or to at most {bound:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
problems.extend(_price_problems(opts, notes))
|
||||
|
||||
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
||||
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
||||
@@ -1173,6 +1202,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
|
||||
"but it only adds overshoot, so 0 is recommended.",
|
||||
integ_w, max_w)
|
||||
_advise(notes, "warn", ("integrator_max_w",),
|
||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
||||
"adds overshoot, so set it to 0.")
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
@@ -1197,6 +1229,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
|
||||
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
|
||||
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||
f"{S('maintenance_discharge_w')} is above "
|
||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
@@ -1205,7 +1241,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
return problems
|
||||
|
||||
|
||||
def _price_problems(opts: dict) -> list[str]:
|
||||
def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
||||
|
||||
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
||||
@@ -1423,6 +1459,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"%s has negative levies %s EUR/kWh, which are subtracted from "
|
||||
"the price. This is allowed, but it is usually a sign error.",
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
||||
"lower the price, and that is usually a sign error.")
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1478,6 +1517,10 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"price_formula is filled in, but price_provider is %r, so the "
|
||||
"formula is not used. Set price_provider to 'custom_formula' "
|
||||
"or clear price_formula.", pid)
|
||||
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||
f"{S('price_formula')} is not used, because "
|
||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
||||
"it or choose that source.")
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1491,6 +1534,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# exactly right and a site refused over one is a site that
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
_advise(notes, "warn", ("price_formula",),
|
||||
_card_note("price_formula", spec))
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
@@ -1571,6 +1616,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"%.4g, offset %+.5f EUR/kWh).",
|
||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
||||
"only the shape of its numbers is checked.")
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
@@ -1582,6 +1630,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
xaged = stale_card(xspec)
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
@@ -1593,6 +1643,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"price_provider is %r but static_profile is empty, so a price "
|
||||
"feed outage leaves no tariff at all. Fill in static_profile as a "
|
||||
"fallback.", pid)
|
||||
_advise(notes, "warn", ("static_profile",),
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.")
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1644,6 +1697,331 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _card_note(option: str, spec) -> str:
|
||||
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||
"the new figures if your contract changed.")
|
||||
|
||||
|
||||
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
||||
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
||||
WATT_ENTITY_KEYS = (
|
||||
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
||||
"p1_phase_net_entities", "p1_phase_import_entities",
|
||||
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
||||
|
||||
|
||||
# The meter entities each `meter_source` reads through Home Assistant.
|
||||
METER_ENTITY_KEYS = {
|
||||
"off": ("meter_entity",),
|
||||
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
|
||||
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
|
||||
"p1_phase_import_entities", "p1_phase_export_entities")}
|
||||
|
||||
|
||||
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||
"""(key, inverter name or "", entity id) for each entity the add-on reads
|
||||
with these options. Keys the chosen meter source or roster shape ignores
|
||||
are left out, so a stale default is not reported."""
|
||||
out = []
|
||||
|
||||
def add(key, value, where=""):
|
||||
for e in (value if isinstance(value, list) else [value]):
|
||||
if isinstance(e, str) and e.strip():
|
||||
out.append((key, where, e.strip()))
|
||||
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
for key in METER_ENTITY_KEYS.get(source, ()):
|
||||
add(key, opts.get(key))
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
||||
add(key, opts.get(key))
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
where = _unit_label(opts, i).strip().strip("()")
|
||||
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
||||
add(key, entry.get(key), where)
|
||||
solar = [e for e in (opts.get("pv_entities") or []) if str(e or "").strip()]
|
||||
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
|
||||
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
|
||||
add(key, opts.get(key))
|
||||
return out
|
||||
|
||||
|
||||
def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
month_peak_w=None) -> list[Note]:
|
||||
"""UX-13: warnings that need live data, for the settings page. Never a
|
||||
refusal, and nothing here changes what `validate_startup_config()` says.
|
||||
|
||||
`entities` is Home Assistant's entity list by id (app/haentities.py), or
|
||||
None when it could not be read, in which case those checks are skipped.
|
||||
`month_peak_w` is this month's peak so far, or None.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
_entity_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
||||
return notes
|
||||
|
||||
|
||||
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
"""A note for each entity these options read that Home Assistant does not
|
||||
have, or that reads in kW where watts are taken. A missing entity whose
|
||||
key is in `bad` is a "bad" note, which blocks the setup wizard."""
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard's last step
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Below this a battery or solar reading counts as idle for a sign check.
|
||||
IDLE_W = 50.0
|
||||
# Meter + solar + battery is the house's use, never below zero. Below minus
|
||||
# this, read seconds apart, a direction is reversed (a battery giving power).
|
||||
BALANCE_W = 300.0
|
||||
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
||||
# MQTT) is only known live when the running options read the same one.
|
||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
||||
|
||||
|
||||
def power_request_entities(opts: dict) -> list:
|
||||
"""The power request entity of each inverter these options name: the flat
|
||||
one, or each entry's. Blank ones are left out."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
values = [opts.get("setpoint_entity")]
|
||||
else:
|
||||
values = [e.get("setpoint_entity") for e in entries
|
||||
if isinstance(e, dict)]
|
||||
return [str(v).strip() for v in values
|
||||
if isinstance(v, str) and v.strip()]
|
||||
|
||||
|
||||
def _reading(entities, entity, convert=False):
|
||||
"""(number, unit) of an entity's state, or (None, state text). The number
|
||||
is as the add-on reads it: raw for the meter and battery power, which it
|
||||
takes as watts, and in W for solar (`convert`), which pv.py converts."""
|
||||
row = (entities or {}).get(entity) or {}
|
||||
unit = str(row.get("unit") or "")
|
||||
try:
|
||||
value = float(row.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None, str(row.get("state") or "nothing")
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return None, str(row.get("state"))
|
||||
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
|
||||
|
||||
|
||||
def _signed(w) -> str:
|
||||
return ("-" if w < 0 else "") + messages.watts(w)
|
||||
|
||||
|
||||
def _cards(opts: dict) -> list:
|
||||
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [("", {k: opts.get(k) for k in (
|
||||
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
|
||||
out = []
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
name = _unit_label(opts, i).strip().strip("()")
|
||||
out.append((f" for {name}" if name else "", entry))
|
||||
return out
|
||||
|
||||
|
||||
def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
meter_now_w=None, prices_known=None) -> list[Note]:
|
||||
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
|
||||
|
||||
Live checks on the proposed options: the meter reads, each power request
|
||||
entity exists in Home Assistant (a write to a missing one is answered 200,
|
||||
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
||||
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
||||
request or meter entity, which the wizard will not save. Reads nothing itself:
|
||||
`entities` is Home Assistant's list by id (None when it did not answer),
|
||||
`running` the options this process started with, `meter_now_w` its meter
|
||||
reading and `prices_known` how many of today's hours have a price.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is None:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.")
|
||||
else:
|
||||
# A missing meter entity is red too (UX-15): with the kettle test
|
||||
# skipped, nothing else on the way to the save would catch it.
|
||||
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
|
||||
_entity_notes(notes, opts, entities,
|
||||
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
|
||||
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
||||
batts = [_card_checks(notes, who, entry, entities)
|
||||
for who, entry in _cards(opts)]
|
||||
solar = []
|
||||
if entities is not None:
|
||||
flipped = pv.inverted(opts)
|
||||
for entity in pv.entities(opts):
|
||||
w, _unit = _reading(entities, entity, convert=True)
|
||||
if entity not in entities or w is None:
|
||||
solar.append(None)
|
||||
continue
|
||||
if w < -IDLE_W and entity not in flipped:
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
known = [meter] + batts + solar
|
||||
if solar and batts and all(w is not None for w in known) \
|
||||
and sum(known) < -BALANCE_W:
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
|
||||
def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
"""The meter line. Returns the grid reading in W (positive: taken from
|
||||
the grid) as the add-on will read it, or None when it is not known."""
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
if source == "off":
|
||||
parts = [(opts.get("meter_entity"), 1.0)]
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
parts = [(opts.get("p1_net_entity"), 1.0)]
|
||||
elif source == SOURCE_HA:
|
||||
parts = [(opts.get("p1_import_entity"), 1.0),
|
||||
(opts.get("p1_export_entity"), -1.0)]
|
||||
else:
|
||||
same = isinstance(running, dict) and all(
|
||||
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
||||
if same and meter_now_w is not None:
|
||||
_meter_line(notes, float(meter_now_w))
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
total = 0.0
|
||||
for entity, sign in parts:
|
||||
entity = str(entity or "").strip()
|
||||
if not entity or entity not in entities:
|
||||
return None # a refusal or a missing-entity note says so
|
||||
w, text = _reading(entities, entity)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
total = -total
|
||||
_meter_line(notes, total)
|
||||
return total
|
||||
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities):
|
||||
"""The checks for one inverter's card. Returns its battery power in W,
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
||||
"holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
return None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def _price_check(notes, opts, running, prices_known) -> None:
|
||||
provider = str(opts.get("price_provider") or "off").strip()
|
||||
profile = str(opts.get("static_profile") or "").strip()
|
||||
if provider in ("", "off"):
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -670,11 +670,10 @@ class SignWatchdog:
|
||||
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
|
||||
# and it defaults to the wording that shipped - "a meter reading" - so a
|
||||
# one-inverter install's `reason` is byte-identical to what an operator
|
||||
# (and test_watchdog.py) has always read. A per-unit instance names what
|
||||
# it actually compared instead, because "commanded +1500 W against a
|
||||
# meter reading -1500 W" would be a false statement about which
|
||||
# instrument produced the evidence, on the one line somebody woken by
|
||||
# this alarm reads first.
|
||||
# has always read (test_watchdog.py pins its meaning). A per-unit
|
||||
# instance names what it actually compared instead, because "commanded
|
||||
# +1500 W against a meter reading -1500 W" would be a false statement
|
||||
# about which instrument produced the evidence.
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
|
||||
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
|
||||
|
||||
+206
-90
@@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it
|
||||
says what is wrong in words.
|
||||
|
||||
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
|
||||
`/` is the status and control surface and is unchanged. `/config` is the
|
||||
`/` is the status and control surface; since UX-13 P1 its files are in
|
||||
app/static/ and its Home screen reads the structured half of `/status`
|
||||
(app/homeview.py). `/config` is the
|
||||
configuration surface: its markup lives in app/config_page.py, its field model
|
||||
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
|
||||
stays HERE is the routing, so that the question a reviewer has to answer - does
|
||||
@@ -24,97 +26,62 @@ routes below read `GET /addons/self/info`, run the SHIPPED validator, write
|
||||
mode or reaches `controller.handle_action()`. The set of things an operator can
|
||||
do to the inverter through this panel is exactly what `/action` offered before
|
||||
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
||||
actions and comparing them with the shipped set.
|
||||
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
|
||||
of Home Assistant's entity list (app/haentities.py) and writes nothing.
|
||||
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
||||
not options is a Store key, `setup_done`, written when the wizard's save
|
||||
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
||||
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
|
||||
commands nothing; there `/action` answers 409.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GoodWe RS485 Controller</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
||||
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
||||
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
h1{font-size:18px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px;margin-bottom:12px}
|
||||
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
|
||||
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
|
||||
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
|
||||
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.k{color:var(--muted);font-weight:400}
|
||||
ul{margin:6px 0 0;padding-left:18px}
|
||||
li{margin:3px 0}
|
||||
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
|
||||
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
|
||||
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
|
||||
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
|
||||
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
||||
.muted{color:var(--muted);font-size:13px}
|
||||
</style></head><body>
|
||||
<h1>GoodWe RS485 Controller</h1>
|
||||
<p class="muted"><a href="config">Configuration →</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
|
||||
@@ -140,9 +107,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
if selfcfg is None:
|
||||
selfcfg = selfconfig.SelfConfig(controller.hass.session,
|
||||
controller.hass.token)
|
||||
entities = EntityCache(controller.hass)
|
||||
index_page = page()
|
||||
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
|
||||
# save can name only the keys that moved underneath the page (finding a).
|
||||
handed: dict = {}
|
||||
|
||||
def hand_out(options):
|
||||
fp = selfconfig.fingerprint(options)
|
||||
handed.pop(fp, None)
|
||||
handed[fp] = options
|
||||
while len(handed) > 16:
|
||||
handed.pop(next(iter(handed)))
|
||||
return fp
|
||||
|
||||
def store_data():
|
||||
store = getattr(controller, "store", None)
|
||||
data = getattr(store, "data", None)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
return web.Response(text=index_page, content_type="text/html")
|
||||
|
||||
async def status(_request):
|
||||
return web.json_response(controller.status_payload())
|
||||
@@ -153,6 +138,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
what = body.get("action")
|
||||
if getattr(controller, "setup_only", False):
|
||||
# UX-15: nothing to start or stop until setup is saved.
|
||||
return web.json_response(
|
||||
{"ok": False, "error": messages.row_setup_only()}, status=409)
|
||||
result = await controller.handle_action(what)
|
||||
if result is None:
|
||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||
@@ -162,15 +151,31 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
async def config_index(_request):
|
||||
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
||||
|
||||
async def _proposed(request):
|
||||
async def _proposed(request, stored=None):
|
||||
"""(body, options, model). UX-13's settings view posts its page
|
||||
`model`, which app/uimodel.py turns into the options blob here, so the
|
||||
validator and the Supervisor only ever see a real blob. The classic
|
||||
page posts `options`. Raises DeriveError for a model no blob can say.
|
||||
|
||||
With `stored` (a save) and the `base` model the page opened, only the
|
||||
keys this page changed are written onto what is stored now, so a save
|
||||
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
model = body.get("model")
|
||||
if isinstance(model, dict):
|
||||
mine = uipage.to_options(model, stored)
|
||||
base = body.get("base")
|
||||
if stored is not None and isinstance(base, dict):
|
||||
was, _errors = uipage.to_options_partial(base, stored)
|
||||
mine = uipage.only_edits(mine, was, stored)
|
||||
return body, mine, model
|
||||
options = body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
return body, options
|
||||
return body, options, None
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
@@ -195,10 +200,25 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"fingerprint": hand_out(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
# UX-13 P1: the levies preset and its year, for the P2 Settings
|
||||
# screens, which show the year beside the region choice.
|
||||
"region": region_of(options),
|
||||
# UX-13 P2: the Installer view draws from these two.
|
||||
"layout": uipage.layout(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang),
|
||||
"ui": options_to_ui(options),
|
||||
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||
"setup": uipage.setup_spec(),
|
||||
"setup_needed": uipage.setup_needed(
|
||||
options, store_data(), bool(getattr(controller, "auto", False)),
|
||||
fresh=getattr(getattr(controller, "store", None), "fresh",
|
||||
False) is True),
|
||||
# UX-15: this process serves setup only and commands nothing.
|
||||
"setup_only": getattr(controller, "setup_only", False) is True,
|
||||
})
|
||||
return web.json_response(model)
|
||||
|
||||
@@ -215,16 +235,58 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
product this card is delivering; anything this layer did to them would
|
||||
be a second opinion about a rule it does not own.
|
||||
"""
|
||||
_body, options = await _proposed(request)
|
||||
try:
|
||||
_body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
model = _body.get("model")
|
||||
errors = []
|
||||
if isinstance(model, dict):
|
||||
# UX-15 F1: a part the page cannot turn into options blocks its
|
||||
# own step, and everything else is still checked.
|
||||
options, errors = uipage.to_options_partial(model)
|
||||
else:
|
||||
model, options = None, _body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
notes = []
|
||||
problems, unread = await selfconfig.problems_and_ranges(
|
||||
options, controller.hass)
|
||||
options, controller.hass, notes=notes)
|
||||
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
||||
# UX-13: warnings and info beside the refusals. Never block a save.
|
||||
# The entity checks use the picker's cached list and never wait on HA.
|
||||
notes += config_notes(options, entities=entities.peek())
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored}
|
||||
"unanchored": unanchored,
|
||||
"notes": [n._asdict() for n in notes]}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
if model is not None:
|
||||
payload["messages"] = (
|
||||
uipage.derive_messages(errors, problems, notes, model,
|
||||
_keys_of(options)) if errors
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
"""The wizard's live checks: a list at most a few seconds old, and
|
||||
what the running add-on already knows (its meter, today's prices)."""
|
||||
rows, age = await entities.get(max_age=5.0)
|
||||
if age is not None and age >= 30.0:
|
||||
rows = None # Home Assistant is not answering
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
meter_now_w=getattr(controller, "grid", None),
|
||||
prices_known=known)
|
||||
|
||||
def _keys_of(options):
|
||||
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
||||
# sound only because the page posts the WHOLE blob it read - which is
|
||||
@@ -235,34 +297,77 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
|
||||
async def config_save(request):
|
||||
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
||||
body, options = await _proposed(request)
|
||||
# The page model carries no stored values of the keys it shows, so the
|
||||
# blob is settled against what is stored: an unchanged value is written
|
||||
# exactly as stored (uipage.to_options).
|
||||
try:
|
||||
posted = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
stored_now = None
|
||||
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
|
||||
try:
|
||||
stored_now = (await selfcfg.info()).get("options") or {}
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
try:
|
||||
body, options, model = await _proposed(request, stored_now)
|
||||
except DeriveError:
|
||||
model = posted["model"]
|
||||
_opts, errors = uipage.to_options_partial(model, stored_now)
|
||||
messages_ = [uipage.derive_message(e) for e in errors]
|
||||
return web.json_response(
|
||||
{"kind": "invalid", "problems": [], "by_key": {},
|
||||
"unanchored": [], "messages": messages_,
|
||||
"steps": uipage.step_problems(messages_, model)},
|
||||
status=422)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
hass=controller.hass)
|
||||
hass=controller.hass,
|
||||
base=handed.get(expect))
|
||||
except selfconfig.SaveRefused as refused:
|
||||
if refused.kind == "stale":
|
||||
stored = refused.stored or {}
|
||||
# The fields to look at are the ones the OTHER surface moved,
|
||||
# not this page's own edits (UX-13 P2 test, finding a).
|
||||
moved = (refused.changed if refused.moved is None
|
||||
else refused.moved)
|
||||
return web.json_response(
|
||||
{"kind": "stale", "changed": refused.changed,
|
||||
"moved": refused.moved,
|
||||
"changed_fields": (uipage.fields_of(moved, model)
|
||||
if model is not None else []),
|
||||
"stored": stored,
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
# made in front of what changed.
|
||||
"fingerprint": selfconfig.fingerprint(stored)},
|
||||
"fingerprint": hand_out(stored)},
|
||||
status=409)
|
||||
by_key, unanchored = problems_by_field(refused.problems,
|
||||
_keys_of(options))
|
||||
return web.json_response(
|
||||
{"kind": "invalid", "problems": refused.problems,
|
||||
"by_key": by_key, "unanchored": unanchored}, status=422)
|
||||
payload = {"kind": "invalid", "problems": refused.problems,
|
||||
"by_key": by_key, "unanchored": unanchored}
|
||||
if model is not None:
|
||||
payload["messages"] = uipage.anchors(refused.problems, [],
|
||||
model, _keys_of(options))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"],
|
||||
model)
|
||||
return web.json_response(payload, status=422)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("configuration saved from the panel - "
|
||||
"not applied until the add-on restarts",
|
||||
hint=messages.hint("settings_saved"))
|
||||
# `handed` keeps `expect`: another page may have opened the same blob.
|
||||
store = getattr(controller, "store", None)
|
||||
if (model is not None and body.get("setup") == "done"
|
||||
and store is not None):
|
||||
store.set(uipage.SETUP_DONE,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
@@ -290,6 +395,16 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
hint=messages.hint("restart_requested"))
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def ha_entities(request):
|
||||
"""UX-13: Home Assistant's entities for the picker. A read only, and
|
||||
never an error: when Home Assistant is down the list says so."""
|
||||
kind = str(request.query.get("kind", "") or "")[:16]
|
||||
one = str(request.query.get("entity", "") or "")[:255]
|
||||
if one:
|
||||
# UX-13 P4's kettle test: one entity, read now.
|
||||
return web.json_response(await entities.reading(one))
|
||||
return web.json_response(await entities.payload(kind))
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
@@ -300,6 +415,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
web.post("/config/validate", config_validate),
|
||||
web.post("/config/save", config_save),
|
||||
web.post("/config/restart", config_restart),
|
||||
web.get("/ha/entities", ha_entities),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": true,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "off",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "off",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [
|
||||
{
|
||||
"batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"bms_soc_floor": 10,
|
||||
"capacity_kwh": 15.36,
|
||||
"max_w": 5000,
|
||||
"name": "inverter-a",
|
||||
"setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc"
|
||||
},
|
||||
{
|
||||
"batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"bms_soc_floor": 10,
|
||||
"capacity_kwh": 20.48,
|
||||
"max_w": 4600,
|
||||
"name": "inverter-b",
|
||||
"setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc"
|
||||
}
|
||||
],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_export_formula": "",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_formula": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_taxes_profile": "",
|
||||
"price_timeout_s": 10,
|
||||
"pv_entities": [],
|
||||
"pv_entity": "",
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "",
|
||||
"shadow_mode": "auto",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": true,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": true,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 15000,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "manual_csv",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
Binary file not shown.
@@ -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}
|
||||
|
||||
@@ -497,8 +497,13 @@ check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
|
||||
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
|
||||
# bounded; the freeze is what makes it small. A check that read
|
||||
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
|
||||
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
|
||||
# battery that refuses, so the command then drops to 0 W. The freeze still
|
||||
# engaged first, and the event log says so.
|
||||
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
|
||||
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
|
||||
f"rather than the rail",
|
||||
any("saturation freeze" in e for e in ctl_r.events)
|
||||
and max(emitted) < LIVE_MAX_W)
|
||||
|
||||
notes_r = clamp_notes(hass_r)
|
||||
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||
@@ -521,7 +526,8 @@ check(f"the action log records every engagement, coalesced into one row per "
|
||||
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||
f"engagement(s))",
|
||||
len(rows_r) == ctl_r.clamp_wd.episodes
|
||||
and f"{ctl_r.clamp_wd.engagements} engagement(s)" in rows_r[0])
|
||||
and messages.count(ctl_r.clamp_wd.engagements, "engagement")
|
||||
in rows_r[0])
|
||||
|
||||
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
|
||||
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
|
||||
@@ -538,7 +544,8 @@ check(f"a long winding episode does NOT flood the 40-row event log "
|
||||
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
|
||||
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
|
||||
check("...and the count in the row is the counter's, so nothing is lost",
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
|
||||
messages.count(ctl_l.clamp_wd.engagements, "engagement")
|
||||
in nth(rows_l, -1))
|
||||
# The RED row exists only while engaged, and the episode above is short, so it
|
||||
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||
check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
@@ -578,8 +585,12 @@ check("UX-07 item 1: during a bound episode an earlier over-rating count is "
|
||||
|
||||
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
||||
# keeps writing the clamped value. If a later cut changes that it must come here.
|
||||
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
|
||||
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
|
||||
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
|
||||
# with its own reason. That is the loop's decision, not a stop.
|
||||
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
|
||||
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
|
||||
ctl_r.loop_reason == "battery refuses"
|
||||
and ctl_r.target == hass_r.writes[-1])
|
||||
check("...and the controller still reports itself running, not failsafe",
|
||||
ctl_r.auto and not ctl_r.sign_wd.tripped)
|
||||
|
||||
@@ -2051,9 +2062,12 @@ check("it still NOTIFIES (not suppressed), under a title that claims no bug",
|
||||
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
|
||||
"notice still on screen",
|
||||
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
|
||||
# UX-11: the WARNING quotes the code. The reason is on the DEBUG line beside
|
||||
# it (test_messages.py part 6).
|
||||
check("...logged as a WARNING, not an ERROR",
|
||||
any(x.levelno == logging.WARNING and "reached its" in x.getMessage()
|
||||
for x in log_i)
|
||||
any(x.levelno == logging.WARNING
|
||||
and x.getMessage().startswith("The battery gives less than asked: "
|
||||
"OUTPUT_CLAMP.") for x in log_i)
|
||||
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
|
||||
for x in log_i))
|
||||
check("...and the status row is amber, not red",
|
||||
|
||||
@@ -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 "
|
||||
@@ -1147,8 +1156,10 @@ async def _one():
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
||||
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
|
||||
# against a 1 s window, so a hold is not what stops this firing -
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
|
||||
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
|
||||
# "the window was never even opened" below is what still makes it
|
||||
# non-vacuous), so a hold is not what stops this firing -
|
||||
# the reduction is. With a shipped-length hold "nothing was
|
||||
# reported" would be true of a fixture that simply ran out of time.
|
||||
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
|
||||
@@ -1158,7 +1169,10 @@ async def _one():
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
|
||||
c.auto = True
|
||||
hass.ctrl = c
|
||||
await _drive(c, n=20, dt=0.1)
|
||||
# SAFETY-52: from SETTLE_CYCLES of delivering nothing the loop asks 0 W
|
||||
# (the battery refuses), so the run stops just before that. 11 cycles is
|
||||
# still 1.1 s against the 1 s hold.
|
||||
await _drive(c, n=11, dt=0.1)
|
||||
return c, hass, store
|
||||
|
||||
|
||||
|
||||
@@ -1813,6 +1813,11 @@ _S56_ACCUSATIONS = [
|
||||
]
|
||||
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if "battery power reading stopped changing" in r.getMessage()]
|
||||
# UX-11: the ERROR line quotes the code, and the reason itself is on the DEBUG
|
||||
# line logged beside it.
|
||||
_dbg56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if r.levelno == logging.DEBUG
|
||||
and r.getMessage().startswith("Evidence for ")] or [""]
|
||||
|
||||
|
||||
def _s56_row(c):
|
||||
@@ -1855,7 +1860,7 @@ check("PRECONDITION: all four operator-facing surfaces were really produced by "
|
||||
"two are the same string, which is 11b again on the log surface",
|
||||
len(_log56) == 2 and _log56[0] == _log56[1]
|
||||
and all(len(s) > 30 for s in _S56_SURFACES.values())
|
||||
and _r56_ref in _log56[-1])
|
||||
and _log56[-1].endswith(": STALE_READING.") and _r56_ref in _dbg56[-1])
|
||||
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
|
||||
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
||||
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
|
||||
|
||||
`GET ha/entities` reads Home Assistant's states through the add-on's own
|
||||
session (hass.states()) and must never fail the page: part 1 drives the real
|
||||
HomeAssistant client against a local server that answers 502, garbage, the
|
||||
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
|
||||
what is served while Home Assistant is down. Part 3 serves the real route.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import hass as H, haentities as E, web
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
STATES = [
|
||||
{"entity_id": "sensor.grid", "state": "812",
|
||||
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
|
||||
"device_class": "power"}},
|
||||
{"entity_id": "sensor.soc", "state": "54",
|
||||
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
|
||||
"device_class": "battery"}},
|
||||
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
|
||||
{"entity_id": "sensor.pv_kw", "state": "1.2",
|
||||
"attributes": {"unit_of_measurement": "kW"}},
|
||||
{"no_entity_id": True},
|
||||
]
|
||||
|
||||
|
||||
async def serve(handler):
|
||||
app = aioweb.Application()
|
||||
app.router.add_get("/api/states", handler)
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
return runner, site._server.sockets[0].getsockname()[1]
|
||||
|
||||
|
||||
async def read_with(handler, timeout_s=10):
|
||||
runner, port = await serve(handler)
|
||||
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
t0 = time.monotonic()
|
||||
got = await H.HomeAssistant(session, "token").states()
|
||||
read_with.took = time.monotonic() - t0
|
||||
return got
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
async def ok(_r):
|
||||
return aioweb.json_response(STATES)
|
||||
|
||||
|
||||
async def bad_gateway(_r):
|
||||
return aioweb.Response(status=502)
|
||||
|
||||
|
||||
async def garbage(_r):
|
||||
return aioweb.Response(text="<html>", content_type="application/json")
|
||||
|
||||
|
||||
async def not_a_list(_r):
|
||||
return aioweb.json_response({"message": "hi"})
|
||||
|
||||
|
||||
async def slow(_r):
|
||||
await asyncio.sleep(2)
|
||||
return aioweb.json_response(STATES)
|
||||
|
||||
|
||||
async def closed_port():
|
||||
H.CORE_API = "http://127.0.0.1:9/api"
|
||||
async with ClientSession() as session:
|
||||
return await H.HomeAssistant(session, "token").states()
|
||||
|
||||
|
||||
print("1. hass.states() answers None, never an exception")
|
||||
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
|
||||
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())
|
||||
|
||||
|
||||
class FakeHass:
|
||||
def __init__(self):
|
||||
self.answer, self.reads = STATES, 0
|
||||
|
||||
async def states(self):
|
||||
self.reads += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return self.answer
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
print("\n2. the cache, and Home Assistant being down")
|
||||
_h, _c = FakeHass(), Clock()
|
||||
_cache = E.EntityCache(_h, clock=_c)
|
||||
|
||||
|
||||
async def burst():
|
||||
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
|
||||
|
||||
|
||||
_p = asyncio.run(burst())
|
||||
check("five pickers opening at once cost one read of Home Assistant",
|
||||
_h.reads == 1 and all(x == _p[0] for x in _p))
|
||||
_ids = [r["entity_id"] for r in _p[0]["entities"]]
|
||||
check("power entities come first, then the rest, and a broken state is left "
|
||||
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
|
||||
"number.setpoint", "sensor.soc"]
|
||||
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
|
||||
check("each row carries id, name, state, unit and device class",
|
||||
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
|
||||
"state": "812", "unit": "W",
|
||||
"device_class": "power", "match": True})
|
||||
check("the kinds filter as the pickers need",
|
||||
[r["entity_id"] for r in _p[0]["entities"]
|
||||
if E.matches(r, "pct")] == ["sensor.soc"]
|
||||
and [r["entity_id"] for r in _p[0]["entities"]
|
||||
if E.matches(r, "number")] == ["number.setpoint"])
|
||||
_c.t += 10
|
||||
asyncio.run(_cache.payload())
|
||||
check("within 30 s the cached list is served", _h.reads == 1)
|
||||
check("peek() hands the validator the fresh list without a read",
|
||||
set(_cache.peek()) == set(_ids) and _h.reads == 1)
|
||||
_h.answer = None
|
||||
_c.t += 30
|
||||
_down = asyncio.run(_cache.payload())
|
||||
check("Home Assistant down after a good read: the old list, marked old",
|
||||
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
|
||||
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
|
||||
check("...and peek() then gives nothing, so no warning rests on an old list",
|
||||
_cache.peek() is None)
|
||||
|
||||
|
||||
async def down_windows():
|
||||
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
|
||||
hass, clock = FakeHass(), Clock()
|
||||
hass.answer = None
|
||||
cache = E.EntityCache(hass, clock=clock)
|
||||
counts = []
|
||||
for step in (0, 1, 30):
|
||||
clock.t += step
|
||||
await asyncio.gather(*(cache.payload() for _ in range(5)))
|
||||
counts.append(hass.reads)
|
||||
return counts
|
||||
|
||||
|
||||
check("five polls while Home Assistant is down cost one read, the polls just "
|
||||
"after wait for the next window, and that window tries once",
|
||||
asyncio.run(down_windows()) == [1, 1, 2])
|
||||
_h2 = FakeHass()
|
||||
_h2.answer = None
|
||||
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
|
||||
check("Home Assistant down from the start: an empty list that says why",
|
||||
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
|
||||
|
||||
|
||||
print("\n3. the route")
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, hass):
|
||||
self.hass, self.o = hass, {}
|
||||
|
||||
|
||||
async def route(hass, query=""):
|
||||
app = web.build_app(FakeController(hass), selfcfg=object())
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
|
||||
return r.status, await r.json()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
|
||||
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
|
||||
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
|
||||
_st, _body = asyncio.run(route(_h2))
|
||||
check("with Home Assistant down it still answers 200, with the reason",
|
||||
_st == 200 and _body["available"] is False and _body["entities"] == [])
|
||||
check("the words shown are short and plain (STYLE: one sentence)",
|
||||
all(len(t.split()) <= 12 and t.count(".") == 1
|
||||
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
|
||||
print(json.dumps(_body))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -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")
|
||||
@@ -26,6 +26,7 @@ Run `python3 -B test_messages.py --violations` to print what part 3 sees.
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -388,6 +389,9 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
|
||||
"log": (25, True, True),
|
||||
"option-name": (6, False, False),
|
||||
"option-desc": (30, True, True),
|
||||
"note": (25, True, True), # UX-13: a settings-page warning
|
||||
"ui-name": (6, False, False), # UX-13: app/ui_text, a name or choice
|
||||
"ui-desc": (25, False, False), # UX-13: app/ui_text, one-line help
|
||||
}
|
||||
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
|
||||
|
||||
@@ -453,6 +457,10 @@ def collect():
|
||||
if arg is not None:
|
||||
for n, t in strings_in(arg):
|
||||
out.append(("log", f"{f.name}:{n.lineno}", t))
|
||||
elif name.endswith("_advise") and node.args:
|
||||
for n, t in strings_in(node.args[-1], expand=True):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("note", f"{f.name}:{n.lineno}", t))
|
||||
elif re.search(r"problems\.(append|extend)$", name):
|
||||
for a in node.args:
|
||||
for n, t in strings_in(a, expand=True):
|
||||
@@ -475,6 +483,16 @@ def collect():
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
# UX-13: the new page's own words, held to the option texts' rule.
|
||||
for f in sorted((APP / "ui_text").glob("*.json")):
|
||||
def walk_ui(node, path):
|
||||
for key, v in node.items():
|
||||
if isinstance(v, dict):
|
||||
walk_ui(v, f"{path}{key}.")
|
||||
elif isinstance(v, str):
|
||||
out.append(("ui-desc" if key == "description" else
|
||||
"ui-name", f"{f.name}:{path}{key}", v))
|
||||
walk_ui(json.loads(f.read_text(encoding="utf-8")), "")
|
||||
return out
|
||||
|
||||
|
||||
@@ -485,6 +503,8 @@ def problems_of(kind, text):
|
||||
p.append(f"over {lim} words ({len(words(text))})")
|
||||
if ";" in text:
|
||||
p.append("semicolon")
|
||||
if kind in ("note", "ui-desc") and sentences(text) > 1:
|
||||
p.append(f"over 1 sentence ({sentences(text)})")
|
||||
return p + text_problems(text, keys=keys, entities=ents)
|
||||
|
||||
|
||||
@@ -863,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",
|
||||
@@ -877,6 +899,7 @@ DATA = {
|
||||
"main.py:messages.when(brussels(self.tariff_at))",
|
||||
"main.py:messages.duration(TARIFF_MAX_AGE_S)",
|
||||
"main.py:messages.count(len(targets), 'inverter')",
|
||||
"main.py:messages.seconds(self.p1.max_age_s)",
|
||||
# maintenance.py, mqtt.py, p1.py
|
||||
"maintenance.py:phase", "mqtt.py:err", "p1.py:err", "p1.py:self.host",
|
||||
"p1.py:self.topic", "p1.py:poll_s", "p1.py:ingest.max_age_s",
|
||||
@@ -897,7 +920,7 @@ DATA = {
|
||||
"price_history.py:messages.count(len(points), 'price')",
|
||||
"price_history.py:err",
|
||||
"selfconfig.py:messages.count(len(problems), 'problem')",
|
||||
"selfconfig.py:', '.join(changed) or 'none this page also edited'",
|
||||
"selfconfig.py:', '.join(keys) or 'none this page also edited'",
|
||||
"static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw",
|
||||
"tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month",
|
||||
# validate.py: option values and keys (the refusals are part 3's)
|
||||
@@ -1047,20 +1070,35 @@ def _static(spec):
|
||||
return [t for t in out if len(words(t)) > 1]
|
||||
|
||||
|
||||
# UX-11: a WARNING+ line quotes a safety reason by its code only. The reason,
|
||||
# with its figures, is on a DEBUG line beside it and in the action log.
|
||||
SAFETY_REASONS = {
|
||||
"sign": _sign_texts, "circulation": _circ_texts,
|
||||
"delivery": _delivery_texts, "clamp": _clamp_texts, "peak": _peak_texts,
|
||||
"freeze": _freeze_texts, "stale": _stale_texts,
|
||||
}
|
||||
|
||||
|
||||
def _coded(maker):
|
||||
"""The codes a WARNING+ line shows for the reasons `maker` builds, plus
|
||||
the fallback for a restored stop with no reason saved."""
|
||||
return lambda: ([messages.reason_code(t) for t in maker()]
|
||||
+ [messages.reason_code("")])
|
||||
|
||||
|
||||
TEXT = {
|
||||
"main.py:first.sign_wd.reason": _sign_texts,
|
||||
"main.py:inv.sign_wd.reason": _sign_texts,
|
||||
"main.py:inv.sign_wd.reason or '(reason not recorded)'": _sign_texts,
|
||||
"main.py:self.site_sign_wd.reason": _sign_texts,
|
||||
"main.py:self.site_sign_wd.reason or '(reason not recorded)'": _sign_texts,
|
||||
"main.py:self.circ_wd.reason": _circ_texts,
|
||||
"main.py:self.circ_wd.reason or '(reason not recorded)'": _circ_texts,
|
||||
"main.py:inv.delivery_wd.reason": _delivery_texts,
|
||||
"main.py:inv.delivery_wd.reason or '(reason not recorded)'": _delivery_texts,
|
||||
"main.py:self.clamp_wd.reason": _clamp_texts,
|
||||
"main.py:g.reason": _peak_texts,
|
||||
"main.py:self.freeze_wd.reason": _freeze_texts,
|
||||
"main.py:inv.batt_fresh.reason": _stale_texts,
|
||||
"main.py:messages.reason_code(first.sign_wd.reason)": _coded(_sign_texts),
|
||||
"main.py:messages.reason_code(inv.sign_wd.reason)": _coded(_sign_texts),
|
||||
"main.py:messages.reason_code(self.site_sign_wd.reason)":
|
||||
_coded(_sign_texts),
|
||||
"main.py:messages.reason_code(self.circ_wd.reason)": _coded(_circ_texts),
|
||||
"main.py:messages.reason_code(inv.delivery_wd.reason)":
|
||||
_coded(_delivery_texts),
|
||||
"main.py:messages.reason_code(self.clamp_wd.reason)": _coded(_clamp_texts),
|
||||
"main.py:messages.reason_code(g.reason)": _coded(_peak_texts),
|
||||
"main.py:messages.reason_code(self.freeze_wd.reason)":
|
||||
_coded(_freeze_texts),
|
||||
"main.py:messages.reason_code(inv.batt_fresh.reason)": _coded(_stale_texts),
|
||||
"main.py:rep.explain()": _migration_texts,
|
||||
"store.py:rep.explain()": _migration_texts,
|
||||
"main.py:plan.words": _tariff_texts,
|
||||
@@ -1086,6 +1124,26 @@ TEXT = {
|
||||
}
|
||||
|
||||
|
||||
# UX-11: a WARNING+ line, as logged with its built text filled in, is at most
|
||||
# 25 words (STYLE.md). These families are not safety reasons and were not
|
||||
# rewritten by UX-11. Each is listed with why, the list may only shrink
|
||||
# (LONG_LINE_CAP), and a listed key that no longer runs long fails.
|
||||
LONG_LINE_OK = {
|
||||
"main.py:plan.words": "tariff plan words, TARIFF-11 pins them, follow-up",
|
||||
"static_tariff.py:read.reason": "price feed reasons, follow-up",
|
||||
"main.py:rep.explain()": "state migration report, names saved keys",
|
||||
"store.py:rep.explain()": "state migration report, names saved keys",
|
||||
"main.py:refusal": "write refusals, a software fault only",
|
||||
"main.py:' / '.join(resolution.scope_errors)":
|
||||
"claim scope errors, a software fault only",
|
||||
"maintenance.py:msg": "maintenance gate reasons, follow-up",
|
||||
"price_forecast.py:gate.reason": "trading gate reasons, follow-up",
|
||||
"price_history.py:self.last_error": "price history errors, follow-up",
|
||||
}
|
||||
LONG_LINE_CAP = 9
|
||||
LINE_WORDS = 25
|
||||
|
||||
|
||||
def reason_problems(text):
|
||||
"""STYLE.md's red-alarm shape, for a text a log line quotes."""
|
||||
out = []
|
||||
@@ -1133,10 +1191,12 @@ def _fill(template, fmt_node, key_part, sample):
|
||||
return template # %-style: filled by the caller
|
||||
|
||||
|
||||
def scan_log_lines(files, text=None, data=None):
|
||||
def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
|
||||
"""-> (unclassified keys, stale keys, [(key, sample, problems)])."""
|
||||
text = TEXT if text is None else text
|
||||
data = DATA if data is None else data
|
||||
long_ok = LONG_LINE_OK if long_ok is None else long_ok
|
||||
long_seen = set() if long_seen is None else long_seen
|
||||
seen, bad = set(), []
|
||||
for fname, tree in files:
|
||||
for key, call in dynamic_parts(tree, fname):
|
||||
@@ -1162,6 +1222,11 @@ def scan_log_lines(files, text=None, data=None):
|
||||
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
|
||||
p += [f"as logged: {x}" for x in line_problems(line)
|
||||
+ logged_shape(line) if x not in p]
|
||||
if len(words(line)) > LINE_WORDS:
|
||||
long_seen.add(key)
|
||||
if key not in long_ok:
|
||||
p.append(f"as logged: over {LINE_WORDS} words "
|
||||
f"({len(words(line))})")
|
||||
if p:
|
||||
bad.append((key, sample, p))
|
||||
unknown = sorted(seen - set(data) - set(text))
|
||||
@@ -1171,14 +1236,81 @@ def scan_log_lines(files, text=None, data=None):
|
||||
|
||||
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
|
||||
for f in sorted(APP.glob("*.py"))]
|
||||
_unknown, _stale, _bad = scan_log_lines(_FILES)
|
||||
_long_seen = set()
|
||||
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
|
||||
for k, sample, p in _bad:
|
||||
print(f" BAD {k}: {'; '.join(p)} :: {sample[:110]!r}")
|
||||
print(f" BAD {k}: {'; '.join(p)} :: {sample!r}")
|
||||
check(f"every dynamic part of a WARNING+ line is classified as data or as a "
|
||||
f"built text (unclassified: {_unknown})", not _unknown)
|
||||
check(f"...and no classification is left over from a line that is gone "
|
||||
f"(stale: {_stale})", not _stale)
|
||||
_n_texts = sum(len(m()) for m in TEXT.values() if m is not None)
|
||||
check(f"UX-11: no WARNING+ line runs over {LINE_WORDS} words as logged, except "
|
||||
f"the {len(LONG_LINE_OK)} listed families, and none of them is a safety "
|
||||
f"reason (cap {LONG_LINE_CAP})",
|
||||
len(LONG_LINE_OK) <= LONG_LINE_CAP
|
||||
and not any("reason_code" in k
|
||||
or TEXT.get(k) in SAFETY_REASONS.values()
|
||||
for k in LONG_LINE_OK))
|
||||
check(f"...and every listed family still runs long, so the list only shrinks "
|
||||
f"(no longer long: {sorted(set(LONG_LINE_OK) - _long_seen)})",
|
||||
set(LONG_LINE_OK) <= _long_seen)
|
||||
|
||||
|
||||
def _code_lines_without_evidence(files):
|
||||
"""WARNING+ calls quoting `messages.reason_code(X)` whose next statement
|
||||
is not a DEBUG call that logs X itself (UX-11: nothing is lost)."""
|
||||
out, n = [], 0
|
||||
for fname, tree in files:
|
||||
for node in ast.walk(tree):
|
||||
for body in (getattr(node, f, None) for f in ("body", "orelse")):
|
||||
if not isinstance(body, list):
|
||||
continue
|
||||
for i, st in enumerate(body):
|
||||
call = st.value if isinstance(st, ast.Expr) else None
|
||||
if not (isinstance(call, ast.Call)
|
||||
and LOG_CALL.search(ast.unparse(call.func))):
|
||||
continue
|
||||
coded = [a.args[0] for a in call.args
|
||||
if isinstance(a, ast.Call) and a.args
|
||||
and ast.unparse(a.func) == "messages.reason_code"]
|
||||
if not coded:
|
||||
continue
|
||||
n += 1
|
||||
nxt = body[i + 1] if i + 1 < len(body) else None
|
||||
dbg = (nxt.value if isinstance(nxt, ast.Expr)
|
||||
and isinstance(nxt.value, ast.Call)
|
||||
and ast.unparse(nxt.value.func).endswith(".debug")
|
||||
else None)
|
||||
want = ast.unparse(coded[0])
|
||||
if dbg is None or not any(
|
||||
ast.unparse(a) == want
|
||||
or ast.unparse(a).startswith(want + " or ")
|
||||
for a in dbg.args):
|
||||
out.append(f"{fname}:{call.lineno}")
|
||||
return n, out
|
||||
|
||||
|
||||
_n_coded, _no_evidence = _code_lines_without_evidence(_FILES)
|
||||
check(f"every WARNING+ line that quotes a reason code ({_n_coded}) has the "
|
||||
f"reason itself on a DEBUG line right after it (missing: {_no_evidence})",
|
||||
_n_coded >= 14 and not _no_evidence)
|
||||
_n_m, _m_bad = _code_lines_without_evidence([("x.py", ast.parse(
|
||||
"_LOG.error('Stop: %s.', messages.reason_code(wd.reason))\n"
|
||||
"_LOG.debug('Something else.')\n"))])
|
||||
check("...and a line whose DEBUG evidence was dropped is caught",
|
||||
_n_m == 1 and _m_bad == ["x.py:1"])
|
||||
_evidence_bad = [(n, t, reason_problems(t)) for n, m in SAFETY_REASONS.items()
|
||||
for t in m() if reason_problems(t)]
|
||||
check(f"the safety reasons behind those lines, on the DEBUG line and in the "
|
||||
f"action log, keep the red-alarm shape ({_evidence_bad})",
|
||||
not _evidence_bad)
|
||||
_codes = {n: [messages.reason_code(t) for t in m()]
|
||||
for n, m in SAFETY_REASONS.items()}
|
||||
check(f"...and every one carries a code the line can quote ({_codes})",
|
||||
all(re.fullmatch(r"[A-Z]+(?:_[A-Z]+)+", c)
|
||||
and c not in (messages.NO_REASON_SAVED, messages.NO_REASON_CODE)
|
||||
for cs in _codes.values() for c in cs))
|
||||
check(f"every built text, run for real, obeys the rule, and so does the line "
|
||||
f"that quotes it ({_n_texts} texts, {len(_bad)} bad)", not _bad)
|
||||
_counts = [f"{f}:{n.lineno}" for f, t in _FILES for n in ast.walk(t)
|
||||
@@ -1208,29 +1340,43 @@ _clamp_lines = {
|
||||
"error, write": (_clamp_line("Power request too high"), _w1),
|
||||
"warning, loop": (_clamp_line("The battery gives less than asked"), _at),
|
||||
}
|
||||
_clamp_shown = {k: (f % (r, messages.times(1)) if f else None)
|
||||
_clamp_shown = {k: (f % (messages.reason_code(r), messages.times(1))
|
||||
if f else None)
|
||||
for k, (f, r) in _clamp_lines.items()}
|
||||
for k, v in _clamp_shown.items():
|
||||
print(f" {k}: {v!r} ({len(words(v or ''))} words)")
|
||||
check("the clamp lines for one inverter are at most 3 sentences and 30 words, "
|
||||
"and keep OUTPUT_CLAMP and the numbers",
|
||||
all(v and sentences(v) <= 3 and len(words(v)) <= 30
|
||||
check("UX-11: the clamp lines are at most 25 words and keep OUTPUT_CLAMP and "
|
||||
"the count, and the numbers are in the reason the DEBUG line beside "
|
||||
"them logs",
|
||||
all(v and len(words(v)) <= LINE_WORDS
|
||||
and "OUTPUT_CLAMP" in v and "1 time so far" in v
|
||||
for v in _clamp_shown.values())
|
||||
and all(x in _clamp_shown["error, loop"]
|
||||
for x in ("+9000 W", "+5000 W", "+2000 W", "software fault")))
|
||||
and "+9000" not in v for v in _clamp_shown.values())
|
||||
and "software fault" in _clamp_shown["error, loop"]
|
||||
and all(x in _over for x in ("+9000 W", "+5000 W", "+2000 W")))
|
||||
|
||||
# Mutations: the scan catches what the 2d scan missed.
|
||||
_OLD_CLAMP = ("OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, "
|
||||
"and it was clamped to +2000 W; the loop's command this cycle was "
|
||||
"+440 W. This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
_mut_text = dict(TEXT, **{"main.py:self.clamp_wd.reason": lambda: [_OLD_CLAMP]})
|
||||
_mut_text = dict(TEXT, **{"main.py:messages.reason_code(self.clamp_wd.reason)":
|
||||
lambda: [_OLD_CLAMP]})
|
||||
_u, _s, _b = scan_log_lines([f for f in _FILES if f[0] == "main.py"],
|
||||
text=_mut_text)
|
||||
check("an injected bad reason (the 2d clamp text) is caught, on both clamp "
|
||||
f"lines ({sorted({k for k, _t, _p in _b if 'clamp' in k})})",
|
||||
[k for k, _t, _p in _b].count("main.py:self.clamp_wd.reason") == 2)
|
||||
[k for k, _t, _p in _b].count(
|
||||
"main.py:messages.reason_code(self.clamp_wd.reason)") == 2)
|
||||
# UX-11: the line that quoted the whole STALE_READING reason (60 words on
|
||||
# TEST-23) is caught by the length rule alone.
|
||||
_old_stale = dict(TEXT, **{"main.py:messages.reason_code(inv.batt_fresh.reason)":
|
||||
_stale_texts})
|
||||
_u, _s, _b = scan_log_lines([f for f in _FILES if f[0] == "main.py"],
|
||||
text=_old_stale)
|
||||
check("...and the UX-10 stale line, which quoted the whole reason, is caught "
|
||||
f"for its length ({[p for k, _t, p in _b if 'batt_fresh' in k][:1]})",
|
||||
any("batt_fresh" in k and any(f"over {LINE_WORDS} words" in x for x in p)
|
||||
for k, _t, p in _b))
|
||||
_new_site = ast.parse("_LOG.warning('Something broke: %s', self.new_wd.reason)")
|
||||
_u, _s, _b = scan_log_lines([("main.py", _new_site)])
|
||||
check(f"...and a new line quoting a text nobody classified is caught ({_u})",
|
||||
@@ -1275,6 +1421,8 @@ import logging # noqa: E402
|
||||
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
|
||||
from app.arbiter import Claim # noqa: E402
|
||||
from app.main import Controller, why_key # noqa: E402
|
||||
from app import control as _control # noqa: E402
|
||||
from app import main as _main # noqa: E402
|
||||
|
||||
|
||||
class _Rec(logging.Handler):
|
||||
@@ -1460,6 +1608,11 @@ for _f in sorted(APP.glob("*.py")):
|
||||
_keys = {why_key(s, [Claim.set(s, 0, 0.0, r)]) for s, r in _SOURCES}
|
||||
_keys |= {why_key("failsafe", []),
|
||||
why_key("failsafe", [Claim.set("loop", 0, 1.0, "tracking")])}
|
||||
# SAFETY-52: the loop's claim reason is computed, so the scan cannot see it.
|
||||
_keys |= {why_key("loop", [Claim.set("loop", 0, 0.0, _r)])
|
||||
for _r in (_control.REFUSED, _control.PROBING)}
|
||||
check("...including the loop's two battery-refuses reasons, which get words of "
|
||||
"their own", {_control.REFUSED, _control.PROBING} <= _keys)
|
||||
check(f"the scan found the claims ({len(_SOURCES)}), including the latched "
|
||||
f"per-inverter stop the 2d test saw as 'Other'",
|
||||
("failsafe", "sign disagreement") in _SOURCES and len(_SOURCES) >= 8)
|
||||
@@ -1487,6 +1640,9 @@ class _H:
|
||||
async def call(self, *a, **k):
|
||||
return True
|
||||
|
||||
async def set_number(self, *a, **k):
|
||||
return True
|
||||
|
||||
|
||||
class _S:
|
||||
data = {}
|
||||
@@ -1549,6 +1705,183 @@ _c.why_words = "Keeping the grid near zero: 0 W"
|
||||
check("...and once a cycle decides again, the line is that decision",
|
||||
_c.why_words == "Keeping the grid near zero: 0 W")
|
||||
|
||||
# UX-11 item 3: past the hold, the Why line says which reading is missing,
|
||||
# not "A reading is missing: 0 W". Driven through cycle() for real.
|
||||
_c.inputs_bad_since = _dt.now(_tz.utc) - _td(seconds=20)
|
||||
asyncio.run(_c.cycle())
|
||||
check(f"past the hold, the Why line names the missing reading "
|
||||
f"({_c.why_words!r})",
|
||||
_c.why_words == "No usable meter reading for over 15 seconds: 0 W")
|
||||
|
||||
# UX-11 item 2: the red meter row says the log shows why. A meter that goes
|
||||
# quiet logs one WARNING per episode, not one per cycle, and INFO when back.
|
||||
_c.p1_enabled = True
|
||||
_c.p1 = _p1.P1Ingest(max_age_s=30.0)
|
||||
|
||||
|
||||
def _meter_at(age_s):
|
||||
import time as _time
|
||||
_c.p1.submit(_p1.make_sample(_p1.SOURCE_HA, 500.0, 0.0, phases=1,
|
||||
ingest_mono=_time.monotonic() - age_s))
|
||||
|
||||
|
||||
def _quiet_episodes():
|
||||
# Two quiet episodes close together, then a third 10 minutes on.
|
||||
for age in (1, 40, 45, 50, 1, 2, 60, 65, 1):
|
||||
_meter_at(age)
|
||||
asyncio.run(_c.read_inputs())
|
||||
_c._meter_quiet_warned -= _main.METER_QUIET_REPEAT_S
|
||||
for age in (40, 45, 1):
|
||||
_meter_at(age)
|
||||
asyncio.run(_c.read_inputs())
|
||||
|
||||
|
||||
_mq = [r for r in _captured("goodwe", _quiet_episodes)
|
||||
if "meter" in r.getMessage().lower()]
|
||||
_mq_warn = [r.getMessage() for r in _mq if r.levelno == logging.WARNING]
|
||||
_mq_info = [r.getMessage() for r in _mq if r.levelno == logging.INFO]
|
||||
_mq_dbg = [r.getMessage() for r in _mq if r.levelno == logging.DEBUG]
|
||||
check(f"a meter that goes quiet logs one WARNING per episode, not per cycle, "
|
||||
f"and one INFO when readings return, and a flapping meter at most one "
|
||||
f"pair per {_main.METER_QUIET_REPEAT_S:.0f} s ({_mq_warn}, {_mq_info}, "
|
||||
f"{_mq_dbg})",
|
||||
len(_mq_warn) == 2 and _mq_info == ["Meter readings are back."] * 2
|
||||
and len(_mq_dbg) == 2
|
||||
and _mq_warn[0] == "No meter reading for over 30 seconds, so control "
|
||||
"waits and then sets the battery to 0 W."
|
||||
and len(words(_mq_warn[0])) <= LINE_WORDS)
|
||||
|
||||
# UX-11 item 1: a state file saved before UX-11 holds the long reasons, and
|
||||
# they load unchanged. The ERROR line at start shows the code, and the saved
|
||||
# text goes on the DEBUG line beside it. Old and new files look the same,
|
||||
# because the saved reason's format did not change and it opens with its code.
|
||||
_OLD = {
|
||||
"sign": "SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held "
|
||||
"over 180 s)",
|
||||
"delivery": "UNIT_NOT_DELIVERING: inverter-a is standing at +2000 W and "
|
||||
"delivering +0 W - 2000 W of its share (100 % of it) is not "
|
||||
"arriving - while inverter-b is not in that state. Nothing "
|
||||
"this add-on reads explains it - held for 121 s continuously "
|
||||
"(limit: over 120 s)",
|
||||
"circ": "CIRCULATING_POWER: 5800 W measured going round between the "
|
||||
"inverters for 61 s, peaking at 6000 W, over the limits of 250 W "
|
||||
"and 60 s. The meter cannot see it, because it nets to zero there",
|
||||
}
|
||||
|
||||
|
||||
class _OldStore(_S):
|
||||
data = {"unit/inverter-a/sign_tripped": True,
|
||||
"unit/inverter-a/sign_reason": _OLD["sign"],
|
||||
"unit/inverter-a/delivery_tripped": True,
|
||||
"unit/inverter-a/delivery_reason": _OLD["delivery"],
|
||||
"circ_tripped": True, "circ_reason": _OLD["circ"],
|
||||
"site_sign_tripped": True, "site_sign_reason": ""}
|
||||
|
||||
|
||||
_restart = []
|
||||
_rs = _captured("goodwe", lambda: _restart.append(Controller(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "off", "max_w": 2000},
|
||||
_H(), _OldStore(), _P(), stop=asyncio.Event())))
|
||||
_rs_err = [r.getMessage() for r in _rs if r.levelno == logging.ERROR]
|
||||
_rs_dbg = [r.getMessage() for r in _rs if r.levelno == logging.DEBUG]
|
||||
for _m in _rs_err:
|
||||
print(f" {_m!r} ({len(words(_m))} words)")
|
||||
check("an old state file's long reasons load unchanged, so the stops stay on",
|
||||
_restart and _restart[0].inverters[0].sign_wd.tripped
|
||||
and _restart[0].inverters[0].sign_wd.reason == _OLD["sign"]
|
||||
and _restart[0].inverters[0].delivery_wd.reason == _OLD["delivery"]
|
||||
and _restart[0].circ_wd.reason == _OLD["circ"])
|
||||
check("...each ERROR line at start is one plain sentence with the code, at "
|
||||
f"most {LINE_WORDS} words, and a stop saved without a reason says so",
|
||||
len(_rs_err) == 4
|
||||
and all(len(words(m)) <= LINE_WORDS and "(limits" not in m
|
||||
for m in _rs_err)
|
||||
and any(m.endswith(": SIGN_DISAGREEMENT.") for m in _rs_err)
|
||||
and any(m.endswith(": UNIT_NOT_DELIVERING.") for m in _rs_err)
|
||||
and any(m.endswith(": CIRCULATING_POWER.") for m in _rs_err)
|
||||
and any(m.endswith(f": {messages.NO_REASON_SAVED}.") for m in _rs_err))
|
||||
check("...and the saved text, figures and all, is on a DEBUG line beside it",
|
||||
all(any(t in m for m in _rs_dbg) for t in _OLD.values()))
|
||||
# UX-11 review: `log_level` defaults to info and `events` starts empty, so the
|
||||
# saved figures also go on the action log, where `status` shows them.
|
||||
_rs_ev = _restart[0].events if _restart else []
|
||||
check(f"...and in the action log, which an installer reads at the default "
|
||||
f"log level ({len(_rs_ev)} events)",
|
||||
all(any(t in e for e in _rs_ev) for t in _OLD.values())
|
||||
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
|
||||
|
||||
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
|
||||
_main_src = (APP / "main.py").read_text(encoding="utf-8")
|
||||
check("the action log counts 'engagements' with a real plural and the "
|
||||
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
|
||||
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4412,12 +4412,16 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
|
||||
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
|
||||
f"in the options blob",
|
||||
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||||
# ⚠️ THE "Why" ROW OF THE SAME RENDER, NOT A SECOND READ OF `why_words`. Since
|
||||
# UX-10 the waiting sentence counts its seconds when it is read, so reading the
|
||||
# property again here, after the other renders above, could cross a second
|
||||
# ("1 second" in the row, "2 seconds" now) - CI run 953 on PR #141 did.
|
||||
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
||||
f"({_T11_ON_HELD[1].why_words!r}), so the operator is told which input is "
|
||||
f"({_T11_ON_HELD[0]['Why']!r}), so the operator is told which input is "
|
||||
f"missing rather than being sent to look at a switch that is already on. "
|
||||
f"The reason comes from main.py, not from this file",
|
||||
_T11_ON_HELD[1].why_words
|
||||
and _T11_ON_HELD[1].why_words in _T11_ON_HELD[0]["Arbitrage"])
|
||||
_T11_ON_HELD[0]["Why"].startswith("A reading is missing")
|
||||
and _T11_ON_HELD[0]["Why"] in _T11_ON_HELD[0]["Arbitrage"])
|
||||
check(f"...and a cycle that DID reach the step renders the strategy's own "
|
||||
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
|
||||
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
|
||||
|
||||
@@ -697,13 +697,24 @@ print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
|
||||
check(f"PRECONDITION: the scan found the whole package, not a subset - "
|
||||
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
|
||||
[f.name for f in APP])
|
||||
check("⭐ EXACTLY THREE MODULES CAN SEE THE MATERIAL: `pv.py` (the reader), "
|
||||
"`main.py` (the read and the two operator surfaces) and `mqtt.py` (the "
|
||||
"publication). `control.py`, `arbiter.py`, `allocator.py`, `peakguard.py`, "
|
||||
"`arbitrage.py`, `maintenance.py`, `clamp.py`, `freeze.py`, "
|
||||
"`watchdog.py`, `delivery.py` and `circulation.py` do not mention it in "
|
||||
"any spelling, comments included",
|
||||
_touch == ["main.py", "mqtt.py", "pv.py"])
|
||||
# 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), `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 == ["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 "
|
||||
"caught, so the green above is the tree speaking and not the scanner "
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Runnable check for SAFETY-52. `python3 test_refusal.py`
|
||||
|
||||
A battery that refuses to give power (the BMS cut-out) used to leave the
|
||||
command standing at its last ask for hours: +5000 W for 11.87 h on 2026-09-06,
|
||||
+1940 W for 3 h 55 m on the bus on 2026-09-19. Now a refused direction is held
|
||||
at 0 W, a 200 W probe asks every 15 minutes, and the refusal ends on charge, on
|
||||
an answered probe, on stop/start and when another writer takes the wire.
|
||||
DECISIONS/SAFETY-52 has the record and the replay figures.
|
||||
|
||||
Mutating the modules this file grades? Purge `__pycache__` first, or run
|
||||
`tools/mutation_run.sh`, which does - see MUTATION.md.
|
||||
|
||||
Parts 1-5 drive the REAL `Controller` (read_inputs + cycle, the real
|
||||
SAFETY-37/57 gate) against a scripted battery whose reading stands at exactly
|
||||
-4 W for any request >= -4 W at the cut-out, and follows otherwise. Time is the
|
||||
`now_mono` handed to `read_inputs()`, so 12 hours run in seconds. `OLD` runs the
|
||||
same fixture with `refused=None` forced into `compute()` - release/1.0's law -
|
||||
so every "new" figure is shown beside the one it replaces. Part 4 also runs
|
||||
`compute()` alone, as the bus saw 09-19 before the gate existed.
|
||||
|
||||
Mutations, each on a copy of this tree purged of `__pycache__`, graded with
|
||||
`python3 -B` over test_refusal, test_control, test_clamp, test_telemetry and
|
||||
test_messages (MUTATION.md). Reds are failing checks, on this tree:
|
||||
|
||||
# mutation reds
|
||||
-- ------------------------------------------------------- ---------------
|
||||
R1 control.py: the engage `if` -> `if False and ...` 8 here + 1 clamp
|
||||
(release/1.0's behaviour)
|
||||
R2 control.py: the exit on the other direction removed 1
|
||||
R3 control.py: the exit on an answered probe removed 2
|
||||
R4 control.py: `PROBE_W = 300.0`, above the gate's 250 W 4
|
||||
R5 control.py: the hold keeps the old target, not 0 W 4
|
||||
R6 main.py: `_refusal_from_stale()` returns at once 1
|
||||
R7 main.py: another writer no longer ends the refusal 1
|
||||
R8 main.py: stop/start no longer ends the refusal 1
|
||||
R9 main.py: the probe window never opens 4
|
||||
R10 main.py: seed from a stale stop below `saturation_w` again, 1
|
||||
not below REFUSED_BELOW_W (review of #141). The same mutation
|
||||
in control.py's engage clause SURVIVES, and that is expected:
|
||||
the delivery exit (R11) ends such a refusal in the same cycle.
|
||||
R11 control.py: only an answered PROBE ends the refusal 2
|
||||
(`and probe` back in the delivery exit; review of #141)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import control, freshness
|
||||
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W, REFUSED, PROBING
|
||||
from app.main import Controller, why_key
|
||||
from app.arbiter import Claim
|
||||
from app import messages
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
|
||||
"deadband_w": 15, "step_w": 10}
|
||||
TICK = 5.0 # the meter's measured median tick (replay_recorded_loop)
|
||||
CUT_W = -4.0 # what the register reads at the cut-out, 2026-09-06
|
||||
H = 3600.0
|
||||
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, values):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""House load, and a battery that follows unless `cut(t)` says it refuses.
|
||||
|
||||
At the cut-out the reading is exactly CUT_W for any request >= CUT_W (a flat
|
||||
register, which is what arms or does not arm the stale-reading gate). A
|
||||
charge request is followed: the pack accepts charge at the floor.
|
||||
`cap(t)` limits discharge while not cut (the 09-06 pack gave 4570 W at 10 %).
|
||||
"""
|
||||
|
||||
def __init__(self, load, cut, cap=lambda t: 1e9):
|
||||
self.load, self.cut, self.cap = load, cut, cap
|
||||
self.t = 0.0
|
||||
self.cmd = 0.0
|
||||
self.writes = []
|
||||
|
||||
def batt(self):
|
||||
if self.cut(self.t) and self.cmd >= CUT_W:
|
||||
return CUT_W
|
||||
return float(round(min(self.cmd, self.cap(self.t))))
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
b = self.batt()
|
||||
return {"sensor.grid": self.load(self.t) - b, "sensor.soc": 9.0,
|
||||
"sensor.batt": b}.get(entity)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((self.t, float(value)))
|
||||
self.cmd = float(value)
|
||||
return True
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
|
||||
def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
"""Real cycles every TICK seconds of `now_mono`. `events[t] = fn(c)` pokes
|
||||
the controller (stop, start, another writer) at that instant.
|
||||
|
||||
Returns [(t, grid, batt, setpoint, i_w, loop_reason, batt_refused)], the
|
||||
number of gate trips, and the controller."""
|
||||
async def go():
|
||||
hass = _Hass(load, cut, cap)
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
|
||||
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||
import app.main as m
|
||||
real, real_dt = m.compute, m.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
"""`cycle()`'s wall clock follows `now_mono`, so the 15 s input hold
|
||||
and the gate's release run in simulated seconds too."""
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
if old:
|
||||
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||
rows, trips, was = [], 0, False
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
hass.t = clock[0] = t
|
||||
for et, fn in list((events or {}).items()):
|
||||
if et <= t:
|
||||
fn(c)
|
||||
del events[et]
|
||||
await c.read_inputs(now_mono=t)
|
||||
stale = c.inverters[0].batt_fresh.stale
|
||||
trips += stale and not was
|
||||
was = stale
|
||||
await c.cycle()
|
||||
rows.append((t, hass.load(t) - hass.batt(), hass.batt(),
|
||||
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
|
||||
t += TICK
|
||||
finally:
|
||||
m.compute, m.datetime = real, real_dt
|
||||
return rows, trips, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def at(rows, t):
|
||||
return min(rows, key=lambda r: abs(r[0] - t))
|
||||
|
||||
|
||||
def longest_hold_h(rows, floor_w=250.0, until=None):
|
||||
"""Longest run of one identical request at or above `floor_w`, in hours."""
|
||||
best = cur = 0.0
|
||||
last = None
|
||||
for r in rows:
|
||||
if until is not None and r[0] >= until:
|
||||
break
|
||||
cur = cur + TICK if (r[3] == last and abs(r[3]) >= floor_w) else 0.0
|
||||
last = r[3]
|
||||
best = max(best, cur)
|
||||
return best / H
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the 09-06 shape: a load step takes the request to 5000 W, the battery "
|
||||
"cuts out, the load ends")
|
||||
# ==============================================================================
|
||||
def load906(t):
|
||||
return 1020 if t < 60 else 4800 if t < 162 else 940 if t < 11.9 * H else -600
|
||||
|
||||
|
||||
def cut906(t):
|
||||
return t >= 132
|
||||
|
||||
|
||||
def cap906(t):
|
||||
return 4570.0
|
||||
|
||||
|
||||
old906, otrips906, _ = run(load906, cut906, 12.2 * H, cap906, old=True)
|
||||
new906, ntrips906, c906 = run(load906, cut906, 12.2 * H, cap906)
|
||||
for name, rows, tr in (("old", old906, otrips906), ("new", new906, ntrips906)):
|
||||
print(f" {name}: request at 2 min {at(rows, 120)[3]:+.0f} W, at 6 h "
|
||||
f"{at(rows, 6 * H)[3]:+.0f} W (i_w {at(rows, 6 * H)[4]:+.0f}), longest "
|
||||
f"hold {longest_hold_h(rows):.2f} h, gate trips {tr}")
|
||||
check("PRECONDITION: the old law holds +5000 W for the whole night on this "
|
||||
"fixture, as it did on 2026-09-06 - the fixture reproduces the card",
|
||||
at(old906, 6 * H)[3] == 5000.0 and longest_hold_h(old906) > 11.5)
|
||||
check("the request reaches the rail before the cut, so the new law starts from "
|
||||
"the same place", at(new906, 120)[3] == 5000.0)
|
||||
t0 = next((r[0] for r in new906 if r[5] == REFUSED), None)
|
||||
check(f"after the cut the battery-refuses hold takes the request to 0 W within "
|
||||
f"about a minute (at t={t0})", t0 is not None and 132 < t0 <= 132 + 90
|
||||
and at(new906, t0)[3] == 0.0)
|
||||
check("...and it stays refused overnight, with the integrator at 0, and no "
|
||||
"request of 250 W or more stands for longer than one probe interval",
|
||||
at(new906, 6 * H)[6] == 1 and at(new906, 6 * H)[4] == 0.0
|
||||
and longest_hold_h(new906, until=11.9 * H) < PROBE_EVERY_S / H)
|
||||
check(f"...and the stale-reading gate does not cycle: {ntrips906} trips in "
|
||||
f"12 hours (the old law's latch also kept it quiet: {otrips906})",
|
||||
ntrips906 == 0)
|
||||
morning = [r for r in new906 if r[0] >= 11.9 * H + 60]
|
||||
check("when solar makes the grid export, the refusal ends and the battery "
|
||||
"charges - the exit on the other direction",
|
||||
morning and morning[-1][6] == 0 and morning[-1][3] < -400)
|
||||
|
||||
# ==============================================================================
|
||||
print("2. the probe: 200 W every 15 minutes, below the gate's 250 W")
|
||||
# ==============================================================================
|
||||
probes = []
|
||||
for r in new906:
|
||||
if r[3] == PROBE_W and (not probes or r[0] - probes[-1][1] > TICK):
|
||||
probes.append([r[0], r[0]])
|
||||
elif r[3] == PROBE_W:
|
||||
probes[-1][1] = r[0]
|
||||
starts = [p[0] for p in probes]
|
||||
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||
print(f" {len(probes)} probes, first at {starts[0] / 60 if starts else 0:.1f} "
|
||||
f"min, gaps {sorted(set(round(g) for g in gaps))} s, lengths "
|
||||
f"{sorted(set(round(p[1] - p[0] + TICK) for p in probes))} s")
|
||||
check("the probe asks exactly PROBE_W, which is below the stale-reading gate's "
|
||||
"threshold, so a probe can never arm it",
|
||||
PROBE_W < freshness.MOVE_W and all(r[3] in (0.0, PROBE_W)
|
||||
for r in new906 if r[6] == 1))
|
||||
check("there is a probe every 15 minutes through the night (at least 40 in "
|
||||
"11.8 hours), each about 34 s long",
|
||||
len(probes) >= 40 and all(PROBE_EVERY_S <= g <= PROBE_EVERY_S + PROBE_S
|
||||
+ 2 * TICK for g in gaps)
|
||||
and all(PROBE_S - TICK <= p[1] - p[0] + TICK <= PROBE_S + TICK
|
||||
for p in probes))
|
||||
check("...and a probe reads as a probe on the loop's own reason row",
|
||||
any(r[5] == PROBING for r in new906))
|
||||
|
||||
# ==============================================================================
|
||||
print("3. recovery with no sign change: the battery gives power again at 2 h")
|
||||
# ==============================================================================
|
||||
def cut_rec(t):
|
||||
return 132 <= t < 2 * H
|
||||
|
||||
|
||||
oldr, _, _ = run(load906, cut_rec, 2.6 * H, cap906, old=True)
|
||||
newr, _, crec = run(load906, cut_rec, 2.6 * H, cap906)
|
||||
after_o = [r for r in oldr if 2 * H <= r[0] < 2 * H + 600]
|
||||
after_n = [r for r in newr if 2 * H <= r[0] < 2.6 * H]
|
||||
exp_o = sum(max(0.0, -r[1]) * TICK for r in after_o) / 3600
|
||||
exp_n = sum(max(0.0, -r[1]) * TICK for r in after_n) / 3600
|
||||
print(f" old: worst grid {min(r[1] for r in after_o):+.0f} W, export "
|
||||
f"{exp_o:.0f} Wh; new: worst grid {min(r[1] for r in after_n):+.0f} W, "
|
||||
f"export {exp_n:.0f} Wh")
|
||||
check("PRECONDITION: the old law answers a recovering battery with its "
|
||||
"standing +5000 W and exports kilowatts into a 940 W house",
|
||||
min(r[1] for r in after_o) < -3000)
|
||||
check("the new law never exports more than the probe while it waits, and the "
|
||||
"answered probe ends the refusal so control resumes",
|
||||
min(r[1] for r in after_n) > -PROBE_W - 50
|
||||
and after_n[-1][6] == 0 and abs(after_n[-1][1] - (-10)) < 100)
|
||||
check("...and the event log says the probe was answered",
|
||||
any("BATTERY REFUSES ENDED: the probe was answered" in e
|
||||
for e in crec.events))
|
||||
|
||||
# ==============================================================================
|
||||
print("4. the 09-19 shape: a stale-reading stop on a frozen ~0 W reading "
|
||||
"is a refusal")
|
||||
# ==============================================================================
|
||||
# The request steps +1540 -> +1940 and more as the battery cuts out mid-ramp:
|
||||
# the request moves after the reading went flat, so the gate trips before the
|
||||
# hold's 12 cycles - the ~70 s cycle SAFETY-59 measured. The trip seeds the hold.
|
||||
def load919(t):
|
||||
return 1530 if t < 60 else 2400 if t < 1800 else 900 if t < 4 * H else -600
|
||||
|
||||
|
||||
def cut919(t):
|
||||
return t >= 65
|
||||
|
||||
|
||||
def run919(old):
|
||||
return run(load919, cut919, 4.2 * H, old=old)
|
||||
|
||||
|
||||
old919, otr919, _ = run919(True)
|
||||
new919, ntr919, c919 = run919(False)
|
||||
print(f" old: gate trips {otr919}, longest hold {longest_hold_h(old919):.2f} h;"
|
||||
f" new: gate trips {ntr919}, longest hold {longest_hold_h(new919):.2f} h")
|
||||
check("PRECONDITION: on the old law the gate trips over and over all night "
|
||||
"(the SAFETY-59 cycle) or the request stands for hours",
|
||||
otr919 > 20 or longest_hold_h(old919) > 1.0)
|
||||
check("the new law stops after at most two trips and holds 0 W, and the event "
|
||||
"log says the battery refuses",
|
||||
ntr919 <= 2 and at(new919, 2 * H)[3] in (0.0, PROBE_W)
|
||||
and any("BATTERY REFUSES" in e for e in c919.events))
|
||||
|
||||
# The same shape through `compute()` alone, as the bus saw it on 2026-09-19
|
||||
# before the gate existed: +1540 -> +1940, then a battery giving nothing.
|
||||
T = control.Tuning(**LIVE)
|
||||
res = {}
|
||||
for name, carry in (("old", None), ("new", 0)):
|
||||
# sat_count 2: the +1540 and +1940 cycles were already short (the plan)
|
||||
prev, i_w, sat, ref, cmds = 1940.0, 2340.0, 2, carry, []
|
||||
for _ in range(2000): # ~2.8 h at 5 s
|
||||
d = control.compute(prev, 1950.0, CUT_W, T, sat, i_w, refused=ref)
|
||||
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||
cmds.append(prev)
|
||||
res[name] = cmds
|
||||
print(f" compute() alone: old stands at {res['old'][-1]:+.0f} W "
|
||||
f"({sum(v == 1940.0 for v in res['old'])} of 2000 cycles at +1940 W), "
|
||||
f"new at {res['new'][-1]:+.0f} W from cycle "
|
||||
f"{res['new'].index(0.0) if 0.0 in res['new'] else None}")
|
||||
check("PRECONDITION: without the gate the old law parks at +1940 W, the 7 065 "
|
||||
"identical writes the bus recorded", set(res["old"]) == {1940.0})
|
||||
check("...and the new law holds 0 W within SETTLE_CYCLES cycles",
|
||||
res["new"].index(0.0) < control.SETTLE_CYCLES
|
||||
and set(res["new"][control.SETTLE_CYCLES:]) == {0.0})
|
||||
|
||||
# ==============================================================================
|
||||
print("5. the other exits: stop, start, another writer")
|
||||
# ==============================================================================
|
||||
def stop(c):
|
||||
c.auto = False
|
||||
|
||||
|
||||
def start(c):
|
||||
c.auto = True
|
||||
|
||||
|
||||
rows, _, c = run(load906, cut906, 1200, cap906, events={600.0: stop,
|
||||
700.0: start})
|
||||
check("stopping control ends the refusal, and starting again begins from "
|
||||
"nothing refused", at(rows, 590)[6] == 1 and at(rows, 650)[6] == 0
|
||||
and at(rows, 705)[6] == 0)
|
||||
|
||||
|
||||
def other_writer(c):
|
||||
c.loop_w = 1234.0 # the arbiter gave the wire something the loop did not ask
|
||||
|
||||
|
||||
rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
|
||||
check("another writer on the wire ends the refusal (the arbiter reseed)",
|
||||
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
|
||||
|
||||
# ==============================================================================
|
||||
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
|
||||
"only late, are not a refusal")
|
||||
# ==============================================================================
|
||||
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
|
||||
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
|
||||
# the time; "gives nothing" is below the probe's own answer.
|
||||
# `compute()` alone: through the Controller the stale-reading gate trips on the
|
||||
# flat 300 W first, and that fixture then passes whatever this threshold is.
|
||||
def derated(carry, cap_w=300.0, n=1800):
|
||||
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
|
||||
b = 0.0
|
||||
for _ in range(n): # 2.5 h at 5 s
|
||||
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
|
||||
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||
held += bool(ref)
|
||||
b = max(0.0, min(prev, cap_w)) # one tick late
|
||||
gave += b
|
||||
return gave / n, held
|
||||
|
||||
|
||||
T = control.Tuning(**LIVE)
|
||||
gave_old, _ = derated(None)
|
||||
gave_new, held_new = derated(0)
|
||||
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
|
||||
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
|
||||
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
|
||||
"0 W as refusing, and keeps giving what the old law got from it",
|
||||
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
|
||||
|
||||
|
||||
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
|
||||
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
|
||||
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
|
||||
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
|
||||
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
|
||||
held = engaged = 0
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
then = [c for tc, c in hist if tc <= t - dead_s]
|
||||
b = then[-1] if then else 0.0
|
||||
if t - pub >= publish_s:
|
||||
reg, pub = b, t
|
||||
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
|
||||
sat, i_w, refused=ref)
|
||||
engaged += bool(d.refused) and not ref
|
||||
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||
hist.append((t, prev))
|
||||
held += tick * bool(ref)
|
||||
t += tick
|
||||
return engaged, held
|
||||
|
||||
|
||||
lag1 = lagging(1.0)
|
||||
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
|
||||
f"time(s), held {lag1[1]:.0f} s")
|
||||
check("a healthy pack whose reading is merely late is let go as soon as the "
|
||||
"reading shows it delivering - not held until the next probe",
|
||||
lag1[1] <= 10.0)
|
||||
|
||||
|
||||
async def _seed(held_w):
|
||||
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off"}, **LIVE),
|
||||
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto, c.sat_count = True, c.tuning.saturation_cycles
|
||||
inv = c.inverters[0]
|
||||
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
|
||||
c._refusal_from_stale(inv, 100.0)
|
||||
return c.batt_refused
|
||||
|
||||
|
||||
check("a stale-reading stop on a register standing at 300 W is not a refusal "
|
||||
"(it gives something), and one at -4 W is",
|
||||
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
|
||||
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
|
||||
check("...i.e. a held direction that reads 100 W or more ends the refusal "
|
||||
"without waiting for a probe", d.refused == 0)
|
||||
|
||||
# ==============================================================================
|
||||
print("6. words")
|
||||
# ==============================================================================
|
||||
w = messages.why(why_key("loop", [Claim.set("loop", 0, 0.0, REFUSED)]), 0.0)
|
||||
wp = messages.why(why_key("loop", [Claim.set("loop", 0, PROBE_W, PROBING)]),
|
||||
PROBE_W)
|
||||
print(f" {w!r} / {wp!r}")
|
||||
check("the page's Why line says the battery refuses, not 'Keeping the grid "
|
||||
"near zero: 0 W'", w == "The battery refuses to give power: 0 W")
|
||||
check("...and a probe says what it is", wp.startswith("Testing if the battery"))
|
||||
check("...and an ordinary loop cycle still reads as the loop",
|
||||
why_key("loop", [Claim.set("loop", 0, 10.0, "tracking")]) == "loop")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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")
|
||||
@@ -636,12 +636,18 @@ _app = web.build_app(FakeController(), FakeSupervisor())
|
||||
_routes = sorted({(r.method, r.get_info().get("path"))
|
||||
for r in _app.router.routes() if r.method != "HEAD"})
|
||||
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
|
||||
check("the configuration surface adds five routes and touches none of the "
|
||||
"three that were there",
|
||||
check("the configuration surface adds five routes and UX-13 one GET, and "
|
||||
"none touches the three that were there",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
("GET", "/status"), ("POST", "/action"),
|
||||
("POST", "/config/restart"), ("POST", "/config/save"),
|
||||
("POST", "/config/validate")])
|
||||
("GET", "/ha/entities"), ("GET", "/status"),
|
||||
("POST", "/action"), ("POST", "/config/restart"),
|
||||
("POST", "/config/save"), ("POST", "/config/validate")])
|
||||
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
|
||||
encoding="utf-8").read()
|
||||
check("UX-13's entity list only reads: haentities.py calls hass.states() and "
|
||||
"no service, setter or action",
|
||||
"hass.states()" in _ents_src
|
||||
and not re.search(r"\.(call|set_number|handle_action|post)\(", _ents_src))
|
||||
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
|
||||
encoding="utf-8").read()
|
||||
for forbidden in ("set_number", "write_setpoint", "handle_action",
|
||||
@@ -692,6 +698,10 @@ async def _e2e():
|
||||
async with s.post(f"{root}/config/validate",
|
||||
json={"options": base(setpoint_entity="")}) as r:
|
||||
out["invalid"] = await r.json()
|
||||
async with s.post(f"{root}/config/validate",
|
||||
json={"options": base(integrator_max_w=3000)}
|
||||
) as r:
|
||||
out["warned"] = await r.json()
|
||||
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
|
||||
# `sup.writes == []` after the whole sequence would be asserting
|
||||
# about the LAST request, not this one - the first draft did, and
|
||||
@@ -749,6 +759,11 @@ check("...and it honours the browser's language per key, falling back to "
|
||||
check("POST config/validate returns the validator's sentences, anchored",
|
||||
E["invalid"]["problems"]
|
||||
and "setpoint_entity" in E["invalid"]["by_key"])
|
||||
check("UX-13: POST config/validate also returns the validator's warnings, "
|
||||
"beside no refusal",
|
||||
E["warned"]["problems"] == [] and [n["keys"] for n in
|
||||
E["warned"]["notes"]]
|
||||
== [["integrator_max_w"]] and E["warned"]["notes"][0]["tier"] == "warn")
|
||||
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
|
||||
E["save_invalid"][0] == 422
|
||||
and E["save_invalid"][1]["kind"] == "invalid"
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""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. Only the "
|
||||
"field the other surface changed, not the page's own edit (P4, P2 "
|
||||
"test finding a)",
|
||||
_r[0] == 409 and _r[1]["kind"] == "stale"
|
||||
and _r[1]["changed_fields"] == ["peak_cap_w"] 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")
|
||||
@@ -0,0 +1,888 @@
|
||||
"""UX-13 P4: the setup wizard. `python3 test_setup.py`
|
||||
|
||||
The wizard is the Installer view's page model drawn one step at a time from
|
||||
each field's `wizard` step (app/uilayout.py), saved through the same routes.
|
||||
Part 1 checks the steps and the tables the page reads. Part 2 the kettle
|
||||
test's sign logic. Part 3 which refusals block which step. Part 4 the
|
||||
first-run rule on new and existing sites. Part 5 the last step's live checks.
|
||||
Part 6 the save through the P2 path, and every blob the wizard can produce
|
||||
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
|
||||
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
|
||||
save names only what changed elsewhere (P2 test, finding a). Part 8 the
|
||||
words. Part 9 UX-15's fixes: F1, F2 with two tabs, a missing meter entity and
|
||||
step 1 after a skip. The headless-browser click-through is in the PR.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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, ContentTypeError, web as aioweb # noqa: E402
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
import ux13_demo as D # noqa: E402
|
||||
import ux13_wizard_blobs as B # noqa: E402
|
||||
from app import uilayout as L, uimodel as U, uipage, web # noqa: E402
|
||||
from app.validate import setup_checks, validate_startup_config # 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)
|
||||
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
|
||||
UI_TEXT = uipage.ui_text()
|
||||
WS = UI_TEXT["settings"]["setup"]
|
||||
WS_D = UI_TEXT["settings"]["derive"]
|
||||
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
||||
encoding="utf-8").read()
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. six steps, drawn from the layout table")
|
||||
# --------------------------------------------------------------------------- #
|
||||
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
|
||||
TOP = [f for g in LAYOUT["groups"] for p in g["parts"] for f in p["fields"]]
|
||||
BY_STEP = {n: [f["key"] for f in TOP if f["wizard"] == n]
|
||||
for n in range(1, L.WIZARD_STEPS + 1)}
|
||||
print(f" {BY_STEP}")
|
||||
check("every field reaches the page with its wizard step",
|
||||
all("wizard" in f for f in TOP)
|
||||
and all(s["wizard"] == 2 for f in TOP for s in f.get("fields", [])))
|
||||
check("the mockup's six steps: meter, inverters, solar, prices, peak limit, "
|
||||
"check and start",
|
||||
BY_STEP[1][0] == "meter_source" and BY_STEP[2] == ["inverters"]
|
||||
and BY_STEP[3] == ["ui.solar"] and BY_STEP[4][0] == "ui.contract"
|
||||
and BY_STEP[5] == ["peak_cap_w", "peak_forecast_entity"]
|
||||
and BY_STEP[6] == [L.SHADOW[0]]
|
||||
and [WS["steps"][str(n)]["name"] for n in range(1, 7)]
|
||||
== ["Your meter", "Inverters and batteries", "Solar", "Prices",
|
||||
"Peak limit", "Check and start"])
|
||||
check("battery modes are not a step yet (OPT-11): step 5 says they come later",
|
||||
"later update" in WS["modes_later"]["description"]
|
||||
and not any("mode" in k for k in BY_STEP[5]))
|
||||
SPEC = uipage.setup_spec()
|
||||
check("the spec the page reads is the table's",
|
||||
SPEC["steps"] == 6 and SPEC["final"] == 6 and SPEC["optional"] == [3]
|
||||
and SPEC["kettle"]["min_w"] == L.KETTLE_MIN_W
|
||||
and set(SPEC["kettle"]["sources"]) == set(L.KETTLE)
|
||||
and SPEC["shadow"] == {"field": "shadow_mode", "on": "on",
|
||||
"off": "auto"})
|
||||
_src = L.field(L.KETTLE_SOURCE)
|
||||
_choices = {c[0] for f in TOP if f["key"] == L.KETTLE_SOURCE
|
||||
for c in f["choices"]}
|
||||
_bad = []
|
||||
for source, (read, outcomes) in L.KETTLE.items():
|
||||
model = {L.KETTLE_SOURCE: source}
|
||||
for key, _sign in read:
|
||||
fld = L.field(key)
|
||||
if not (fld and fld.wizard == 1 and fld.control == L.ENTITY
|
||||
and L.visible(fld, model)):
|
||||
_bad.append(key)
|
||||
for _say, sets in outcomes.values():
|
||||
for key in (sets or {}):
|
||||
fld = L.field(key)
|
||||
if not (fld and fld.wizard == 1 and L.visible(fld, model)):
|
||||
_bad.append(key)
|
||||
check(f"each kettle source is a meter choice, and reads and sets only step-1 "
|
||||
f"fields shown for it ({_bad})",
|
||||
set(L.KETTLE) <= _choices and not _bad and _src.wizard == 1)
|
||||
_shadow = next((f for f in TOP if f["key"] == L.SHADOW[0]), {})
|
||||
check("the shadow switch writes two of shadow mode's own choices",
|
||||
{L.SHADOW[1], L.SHADOW[2]} <= {c[0] for c in _shadow.get("choices", [])})
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the kettle test's sign logic")
|
||||
# --------------------------------------------------------------------------- #
|
||||
K = uipage.kettle
|
||||
check("one grid entity: up keeps the sign, down flips it",
|
||||
K("off", -10, 1990) == ("ok", {"meter_invert": False}, 2000)
|
||||
and K("off", 400, -1600) == ("flip", {"meter_invert": True}, -2000))
|
||||
check("the direction is the change, not the sign: exporting 3 kW, a kettle "
|
||||
"still moves the reading up",
|
||||
K("off", -3000, -1000)[0] == "ok" and K("off", 3000, 1000)[0] == "flip")
|
||||
check(f"a change under {L.KETTLE_MIN_W:.0f} W sets nothing and is not passed",
|
||||
K("off", 0, 499)[:2] == ("none", None)
|
||||
and K("off", 0, -499)[:2] == ("none", None)
|
||||
and K("off", 0, 500)[0] == "ok")
|
||||
check("a net entity that goes down cannot be fixed by an option, so the step "
|
||||
"is not passed",
|
||||
K("ha_signed", 0, 2000) == ("ok", {}, 2000)
|
||||
and K("ha_signed", 0, -2000)[:2] == ("wrong_signed", None))
|
||||
check("import and export are read as their difference",
|
||||
L.KETTLE["ha_dsmr"][0] == (("p1_import_entity", 1),
|
||||
("p1_export_entity", -1))
|
||||
and K("ha_dsmr", -3000, -1000)[0] == "ok"
|
||||
and K("ha_dsmr", 0, -2000)[0] == "wrong_dsmr")
|
||||
check("a meter the add-on reads itself has no kettle test on the page",
|
||||
K("homewizard_local", 0, 2000) is None and K("mqtt_p1", 0, 2000) is None)
|
||||
_js = re.search(r"function kettleOutcome\(src, before, after\) \{(.*?)\n \}",
|
||||
JS, re.S)
|
||||
check("the page reaches the same verdict: settings.js kettleOutcome() is "
|
||||
"uipage.kettle() line for line",
|
||||
_js and "const d = after - before;" in _js.group(1)
|
||||
and "Math.abs(d) < M.setup.kettle.min_w" in _js.group(1)
|
||||
and 'src[d > 0 ? "up" : "down"]' in _js.group(1))
|
||||
check("...and it applies what the verdict sets, and passes the step only on "
|
||||
"a verdict that sets something",
|
||||
"if (o.sets) { Object.assign(V, o.sets); queueValidate(); }" in JS
|
||||
and "ok: o.sets != null" in JS)
|
||||
check("a changed meter or entity forgets the result, so the test runs again",
|
||||
"if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null;" in JS)
|
||||
check("each verdict has words", all(say in WS["kettle"] for _r, o in
|
||||
L.KETTLE.values()
|
||||
for say, _s in o.values())
|
||||
and {"none", "unread", "skipped", "self"} <= set(WS["kettle"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. which refusals block which step")
|
||||
# --------------------------------------------------------------------------- #
|
||||
_m = U.options_to_ui(dict(NEW, meter_source="ha_signed"))
|
||||
_msgs = [{"field": "p1_net_entity", "card": None, "tier": "bad", "text": "a"},
|
||||
{"field": "meter_entity", "card": None, "tier": "bad", "text": "b"},
|
||||
{"field": "inverters.setpoint_entity", "card": 0, "tier": "bad",
|
||||
"text": "c"},
|
||||
{"field": "maintenance_charge_w", "card": None, "tier": "bad",
|
||||
"text": "d"},
|
||||
{"field": None, "card": None, "tier": "bad", "text": "e"},
|
||||
{"field": "peak_cap_w", "card": None, "tier": "warn", "text": "f"}]
|
||||
check("a refusal blocks the step of its field, a hidden or non-wizard field's "
|
||||
"and an unplaced one the last step, and a warning blocks nothing",
|
||||
uipage.step_problems(_msgs, _m)
|
||||
== {"1": 1, "2": 1, "3": 0, "4": 0, "5": 0, "6": 3})
|
||||
|
||||
|
||||
class Store:
|
||||
"""app/store.py's shape: a dict under .data, and set()."""
|
||||
|
||||
def __init__(self, data=None, fresh=False):
|
||||
self.data = dict(data or {})
|
||||
self.fresh = fresh
|
||||
|
||||
def set(self, key, value):
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
def state(eid, value, unit="W", cls=None):
|
||||
return {"entity_id": eid, "state": value,
|
||||
"attributes": {"friendly_name": eid, "unit_of_measurement": unit,
|
||||
"device_class": cls}}
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self, states):
|
||||
self.states_now = states
|
||||
self.reads = []
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def states(self):
|
||||
return copy.deepcopy(self.states_now)
|
||||
|
||||
async def state(self, entity):
|
||||
self.reads.append(entity)
|
||||
return next((copy.deepcopy(s) for s in self.states_now
|
||||
if s["entity_id"] == entity), None)
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self, options):
|
||||
self.options = copy.deepcopy(options)
|
||||
self.writes = []
|
||||
self.restarts = 0
|
||||
|
||||
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):
|
||||
self.restarts += 1
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts, states, store=None, auto=False, grid=None):
|
||||
self.o, self.hass, self.inverters = dict(opts), Hass(states), []
|
||||
self.store, self.auto, self.grid = store, auto, grid
|
||||
|
||||
def log_event(self, text, hint=None):
|
||||
pass
|
||||
|
||||
def status_payload(self):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": [],
|
||||
"plan": {"prices": [{"eur_kwh": 0.2}] * 20
|
||||
+ [{"eur_kwh": None}] * 4}}
|
||||
|
||||
async def handle_action(self, what):
|
||||
raise AssertionError(f"the wizard reached {what!r}")
|
||||
|
||||
|
||||
async def session(ctl, stored, steps):
|
||||
sup = Supervisor(stored)
|
||||
runner = aioweb.AppRunner(web.build_app(ctl, 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:
|
||||
try:
|
||||
return r.status, await r.json()
|
||||
except (ValueError, ContentTypeError): # e.g. a plain-text 500
|
||||
return r.status, {}
|
||||
|
||||
|
||||
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
|
||||
state("sensor.inv1_level", "62", "%", "battery"),
|
||||
state("sensor.inv1_battery", "-800"),
|
||||
state("sensor.pv", "-1200", "W", "power"),
|
||||
state("sensor.net", "-3.1", "kW", "power")]
|
||||
|
||||
|
||||
async def _steps(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["inverters"][0]["setpoint_entity"] = ""
|
||||
return (await _post(s, root + "config/validate", {"model": model}))[1]
|
||||
|
||||
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES), NEW, _steps))
|
||||
print(f" blank power request entity: steps {_r['steps']}")
|
||||
check("over HTTP, a blank power request entity blocks step 2 and no other",
|
||||
_r["steps"]["2"] >= 1
|
||||
and all(v == 0 for k, v in _r["steps"].items() if k != "2"))
|
||||
check("the page moves on only when the step's count is 0, the kettle test is "
|
||||
"done on step 1, and the last step saves only with nothing red",
|
||||
"if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); "
|
||||
"firstBad(); return; }" in JS
|
||||
and "if (n === 1 && !kettleOk())" in JS
|
||||
and "if (WZ.blocked) { toast(WS.check.blocked.description); return; }"
|
||||
in JS)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. first run: a new install opens the wizard, a working site not")
|
||||
# --------------------------------------------------------------------------- #
|
||||
S = uipage.setup_needed
|
||||
check("a new install: no Store file at this boot, never set up, control "
|
||||
"never started or stopped",
|
||||
S(NEW, {}, False, fresh=True) == "first_start"
|
||||
and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True)
|
||||
== "first_start")
|
||||
check("a site that runs control, or ever ran or stopped it, is not sent to "
|
||||
"the wizard", S(NEW, {}, True, fresh=True) is None
|
||||
and S(NEW, {"auto": True}, False, fresh=True) is None
|
||||
and S(NEW, {"auto": False}, False, fresh=True) is None)
|
||||
check("a site the wizard saved is not sent again",
|
||||
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False,
|
||||
fresh=True) is None)
|
||||
# Review R1: the Stores the two TEST-23 add-ons really have (2026-09-28): no
|
||||
# `auto` (Start/Stop never pressed, auto_start off), no setup_done, stopped,
|
||||
# one of them in shadow for weeks. A Store read from a file is an old site.
|
||||
_T23_ADDON = dict.fromkeys((
|
||||
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
|
||||
"maint_schema_version", "phase", "phase_started", "schema_version",
|
||||
"sign_reason", "sign_tripped", "site_sign_reason", "site_sign_tripped",
|
||||
"unit/inverter-a/last_completed", "unit/inverter-a/last_start_attempt",
|
||||
"unit/inverter-a/phase", "unit/inverter-a/phase_started",
|
||||
"unit/inverter-a/sign_reason", "unit/inverter-a/sign_tripped"))
|
||||
_T23_ARB = dict.fromkeys((
|
||||
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
|
||||
"phase", "phase_started", "schema_version", "sign_reason", "sign_tripped",
|
||||
"site_sign_reason", "site_sign_tripped", "unit/inverter-a/sign_reason",
|
||||
"unit/inverter-a/sign_tripped"))
|
||||
check("an old site never started (the real TEST-23 Stores, one in shadow) is "
|
||||
"not sent to the wizard: its Store came from a file (review R1)",
|
||||
S(NEW, _T23_ADDON, False) is None and S(NEW, _T23_ARB, False) is None
|
||||
and S(dict(NEW, shadow_mode="on"), _T23_ARB, False) is None
|
||||
and S(NEW, {}, False) is None)
|
||||
check("stored options with no inverter to control always open it, whatever "
|
||||
"the Store says",
|
||||
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
|
||||
== "no_power_request"
|
||||
and S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": " "}]),
|
||||
None, True) == "no_power_request")
|
||||
check("a list roster with a power request entity is set up",
|
||||
S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": "number.a"}]),
|
||||
{"auto": True}, True) is None)
|
||||
check("without a Store the answer is no (never nag on a guess)",
|
||||
S(NEW, None, False) is None)
|
||||
_sites = [(n, dict(DEFAULTS, **json.load(open(os.path.join(
|
||||
HERE, "scenarios", "ux13", "test23", n))))) for n in sorted(os.listdir(
|
||||
os.path.join(HERE, "scenarios", "ux13", "test23")))]
|
||||
_starting = [(n, o) for n, o in _sites if not validate_startup_config(o)]
|
||||
check(f"the TEST-23 sites that start, and run control, are not sent to the "
|
||||
f"wizard ({[n for n, _o in _starting]})", len(_starting) >= 3
|
||||
and all(S(o, {"auto": True, "phase": "idle"}, True) is None
|
||||
for _n, o in _starting))
|
||||
check("...and the one that cannot start (no power request entity) is",
|
||||
[S(o, {"auto": True}, True) for n, o in _sites
|
||||
if (n, o) not in _starting] == ["no_power_request"])
|
||||
|
||||
|
||||
async def _first(s, root, sup):
|
||||
out = {"model": await _get(s, root + "config/model?lang=en")}
|
||||
m = out["model"]
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["peak_cap_w"] = 4000
|
||||
out["plain"] = await _post(s, root + "config/save", {
|
||||
"model": model, "fingerprint": m["fingerprint"]})
|
||||
out["store_plain"] = dict(_store.data)
|
||||
out["after_plain"] = await _get(s, root + "config/model?lang=en")
|
||||
m = out["after_plain"]
|
||||
model = copy.deepcopy(m["ui"])
|
||||
out["wizard"] = await _post(s, root + "config/save", {
|
||||
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
|
||||
out["after"] = await _get(s, root + "config/model?lang=en")
|
||||
return out
|
||||
|
||||
|
||||
_store = Store({"phase": "idle"}, fresh=True)
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
|
||||
check("over HTTP, /config/model says a new install needs setup, with the spec",
|
||||
_r["model"]["setup_needed"] == "first_start"
|
||||
and _r["model"]["setup"] == json.loads(json.dumps(SPEC)))
|
||||
check("a save from the Installer view does not count as setup",
|
||||
_r["plain"][0] == 200 and _r["after_plain"]["setup_needed"]
|
||||
== "first_start" and uipage.SETUP_DONE not in _r["store_plain"])
|
||||
check("the wizard's save writes the Store marker, and the wizard stops "
|
||||
"opening by itself",
|
||||
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
|
||||
and _r["after"]["setup_needed"] is None)
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store(_T23_ARB)), NEW,
|
||||
_first))
|
||||
check("over HTTP, an old site never started (a Store read from a file) is not "
|
||||
"sent to the wizard (review R1)", _r["model"]["setup_needed"] is None)
|
||||
with tempfile.TemporaryDirectory() as _d:
|
||||
from app.store import Store as _RealStore
|
||||
_path = os.path.join(_d, "state.json")
|
||||
_new = _RealStore(_path)
|
||||
_new.set("phase", "idle")
|
||||
_old = _RealStore(_path)
|
||||
with open(_path, "w") as _fh:
|
||||
_fh.write("{not json")
|
||||
_bad = _RealStore(_path)
|
||||
check("app/store.py: fresh only when this boot found no state file (a new "
|
||||
"/data), not after a save and not for an unreadable file",
|
||||
_new.fresh is True and _old.fresh is False and _bad.fresh is False)
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
|
||||
auto=True), NEW, _first))
|
||||
check("a working site's /config/model says no", _r["model"]["setup_needed"]
|
||||
is None)
|
||||
check("the page goes to Setup by itself only when opened with no route and "
|
||||
"only while still on Home",
|
||||
"first: !location.hash" in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read()
|
||||
and 'if (M && M.setup_needed && location.hash === "#home") '
|
||||
'location.hash = "#setup";' in JS)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the last step's live checks")
|
||||
# --------------------------------------------------------------------------- #
|
||||
ROWS = {s["entity_id"]: {"entity_id": s["entity_id"], "state": s["state"],
|
||||
"unit": s["attributes"]["unit_of_measurement"]}
|
||||
for s in STATES}
|
||||
SITE = dict(NEW, meter_entity="sensor.grid", soc_entity="sensor.inv1_level",
|
||||
batt_entity="sensor.inv1_battery", pv_entity="sensor.pv")
|
||||
|
||||
|
||||
def tiers(notes):
|
||||
return [(n.tier, n.text) for n in notes]
|
||||
|
||||
|
||||
_c = tiers(setup_checks(SITE, entities=ROWS))
|
||||
for t in _c:
|
||||
print(f" {t}")
|
||||
check("the meter reads, as the add-on will see it",
|
||||
("ok", "The meter reads 402 W taken from the grid now.") in _c)
|
||||
check("with the sign flipped, the same reading is fed in",
|
||||
("ok", "The meter reads 402 W fed into the grid now.") in tiers(
|
||||
setup_checks(dict(SITE, meter_invert=True), entities=ROWS)))
|
||||
check("the power request entity exists in Home Assistant (a write cannot "
|
||||
"tell: SAFETY-77)",
|
||||
("ok", "Home Assistant has number.inv1_power, the power request "
|
||||
"entity.") in _c)
|
||||
check("the battery level reads, and the battery power with its direction",
|
||||
("ok", "The battery level reads 62 %.") in _c
|
||||
and ("info", "The battery reads 800 W charging now, so check the "
|
||||
"inverter shows the same.") in _c)
|
||||
check("a solar entity below zero, not flipped, looks reversed",
|
||||
("warn", "sensor.pv reads below zero now, so turn on its “Reads "
|
||||
"negative” switch in the Solar step.") in _c
|
||||
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
|
||||
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
|
||||
entities=ROWS)))
|
||||
_miss = tiers(setup_checks(dict(SITE, setpoint_entity="number.typo"),
|
||||
entities=ROWS))
|
||||
check("a power request entity Home Assistant does not have is red, so the "
|
||||
"wizard does not save",
|
||||
("bad", "Home Assistant has no entity number.typo, which “Inverter "
|
||||
"setpoint entity” (setpoint_entity) names.") in _miss)
|
||||
check("a missing battery level entity is a warning, not red (the add-on "
|
||||
"holds 0 W and says so)",
|
||||
[t for t, x in tiers(setup_checks(dict(SITE, soc_entity="sensor.x"),
|
||||
entities=ROWS))
|
||||
if "sensor.x" in x] == ["warn"])
|
||||
_two = dict(DEFAULTS, meter_source="ha_signed", p1_net_entity="sensor.net",
|
||||
inverters=[{"name": "a", "setpoint_entity": "number.inv1_power",
|
||||
"soc_entity": "sensor.inv1_level", "max_w": 2000,
|
||||
"bms_soc_floor": 10},
|
||||
{"name": "b", "setpoint_entity": "number.b",
|
||||
"max_w": 2000, "bms_soc_floor": 10}])
|
||||
_c2 = tiers(setup_checks(_two, entities=ROWS))
|
||||
check("with two inverters each is named, and a kW meter is shown as the "
|
||||
"add-on reads it, beside the warning to pick a sensor in W",
|
||||
("ok", "The meter reads 3 W fed into the grid now.") in _c2
|
||||
and [t for t, x in _c2 if "reads in kW" in x] == ["warn"]
|
||||
and ("bad", "Home Assistant has no entity number.b, which “Inverter "
|
||||
"setpoint entity” for b (inverters.setpoint_entity) names.")
|
||||
in _c2
|
||||
and ("ok", "The battery level for a reads 62 %.") in _c2
|
||||
and ("warn", "There is no battery level entity for b, so pick one in "
|
||||
"step 2 or the add-on holds 0 W.") in _c2)
|
||||
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
|
||||
p1_export_entity="sensor.inv1_level")
|
||||
check("import and export are read as their difference",
|
||||
("ok", "The meter reads 340 W taken from the grid now.")
|
||||
in tiers(setup_checks(dict(_dsmr), entities=dict(
|
||||
ROWS, **{"sensor.inv1_level": {"state": "62", "unit": "W"}}))))
|
||||
_hw = dict(SITE, meter_source="homewizard_local", p1_host="192.0.2.5")
|
||||
check("a meter the add-on reads itself: its live reading when it already "
|
||||
"runs on it, else a note that Home shows it after the restart",
|
||||
("ok", "The meter reads 250 W taken from the grid now.") in tiers(
|
||||
setup_checks(_hw, entities=ROWS, running=_hw, meter_now_w=250))
|
||||
and [t for t, x in tiers(setup_checks(_hw, entities=ROWS, running=SITE,
|
||||
meter_now_w=250))
|
||||
if "meter" in x] == ["info"])
|
||||
check("an unreadable meter is a warning",
|
||||
("warn", "sensor.grid reads unavailable now, so check it in Home "
|
||||
"Assistant before you start.") in tiers(setup_checks(SITE, entities=dict(
|
||||
ROWS, **{"sensor.grid": {"state": "unavailable",
|
||||
"unit": "W"}}))))
|
||||
_down = tiers(setup_checks(SITE, entities=None))
|
||||
check("Home Assistant down: red, because the power request entity cannot be "
|
||||
"checked (review R4), and nothing claimed ok about an entity",
|
||||
_down[0] == ("bad", "Home Assistant is not answering, so press Check "
|
||||
"again once it is back.")
|
||||
and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x])
|
||||
# Review R5: meter + solar + battery is the house's use, never below zero. A
|
||||
# battery giving 1 500 W whose sensor reads it the wrong way (-1 500 W).
|
||||
_rev = dict(ROWS, **{"sensor.pv": {"state": "0", "unit": "W"},
|
||||
"sensor.inv1_battery": {"state": "-1500", "unit": "W"}})
|
||||
_bal = ("warn", "The readings add up to -1 098 W of house use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
check("a battery sensor reading the wrong way while giving power is flagged "
|
||||
"(review R5); not once its switch is on, not without solar, not with "
|
||||
"the battery reading unknown, and not on the normal site",
|
||||
_bal in tiers(setup_checks(SITE, entities=_rev))
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_invert=True),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, pv_entity=""),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_entity=""),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for x in (t[1] for t in _c) if "add up" in x])
|
||||
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
|
||||
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
|
||||
_dyn = dict(SITE, price_provider="generic_dayahead")
|
||||
check("prices: known from a contract; for a feed, today's count when it "
|
||||
"already runs, else after the restart; none when none is set",
|
||||
("ok", "Your prices come from your contract, so they are always known.")
|
||||
in tiers(setup_checks(_fixed, entities=ROWS))
|
||||
and ("ok", "Prices for today are in, for 20 of 24 hours.")
|
||||
in tiers(setup_checks(_dyn, entities=ROWS, running=_dyn,
|
||||
prices_known=20))
|
||||
and ("info", "Prices are fetched after the restart, and Home shows "
|
||||
"them.") in tiers(setup_checks(_dyn, entities=ROWS,
|
||||
running=SITE,
|
||||
prices_known=20))
|
||||
and ("info", "No prices are set, so the batteries do not charge on "
|
||||
"price.") in _c)
|
||||
|
||||
|
||||
async def _final(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["inverters"][0]["setpoint_entity"] = "number.typo"
|
||||
out = {"ok": (await _post(s, root + "config/validate",
|
||||
{"model": m["ui"], "setup": True}))[1],
|
||||
"typo": (await _post(s, root + "config/validate",
|
||||
{"model": model, "setup": True}))[1],
|
||||
"plain": (await _post(s, root + "config/validate",
|
||||
{"model": m["ui"]}))[1]}
|
||||
return out
|
||||
|
||||
|
||||
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
|
||||
SITE, _final))
|
||||
check("over HTTP, the last step gets the checks beside the refusals, read "
|
||||
"from a fresh entity list",
|
||||
[x["tier"] for x in _r["ok"]["setup"] if "number.inv1_power" in x["text"]]
|
||||
== ["ok"] and not [m for m in _r["ok"]["messages"] if m["tier"] == "bad"]
|
||||
and "setup" not in _r["plain"])
|
||||
check("...and a typed power request entity HA does not have is red there",
|
||||
["bad"] == [x["tier"] for x in _r["typo"]["setup"]
|
||||
if "number.typo" in x["text"]])
|
||||
|
||||
|
||||
async def _kettle(s, root, sup):
|
||||
return (await _get(s, root + "ha/entities?entity=sensor.net"),
|
||||
await _get(s, root + "ha/entities?entity=sensor.gone"))
|
||||
|
||||
|
||||
(_k, _gone), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
|
||||
_kettle))
|
||||
check("the kettle test reads one entity now, in W, and says when it is not "
|
||||
"there", _k == {"entity_id": "sensor.net", "found": True,
|
||||
"state": "-3.1", "unit": "kW", "watts": -3100.0}
|
||||
and _gone["found"] is False and _gone["watts"] is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. the save goes through the P2 path and writes a valid blob")
|
||||
# --------------------------------------------------------------------------- #
|
||||
_plan = {"meter": "signed", "roster": "two", "solar": "two",
|
||||
"prices": "daynight", "region": "flanders", "peak": "forecast",
|
||||
"shadow": "on"}
|
||||
|
||||
|
||||
async def _wizard(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
model = B.browser(B.apply(m["ui"], _plan))
|
||||
v = (await _post(s, root + "config/validate", {"model": model,
|
||||
"setup": True}))[1]
|
||||
saved = await _post(s, root + "config/save", {
|
||||
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
|
||||
return v, saved
|
||||
|
||||
|
||||
_store = Store()
|
||||
(_v, _saved), _sup = asyncio.run(session(Controller(NEW, STATES, _store),
|
||||
NEW, _wizard))
|
||||
_blob = _sup.writes[-1] if _sup.writes else {}
|
||||
check("the wizard's model validates clean and saves with 200",
|
||||
not [m for m in _v["messages"] if m["tier"] == "bad"]
|
||||
and _saved[0] == 200 and len(_sup.writes) == 1)
|
||||
check("the blob written starts: the shipped validator accepts it",
|
||||
not validate_startup_config(_blob))
|
||||
check("it has what the six steps set: the net meter, two named inverters, "
|
||||
"two solar rows with one flipped, day and night prices, the Flanders "
|
||||
"levies, the peak limit, and shadow mode on",
|
||||
_blob["meter_source"] == "ha_signed"
|
||||
and [i["name"] for i in _blob["inverters"]] == ["inverter-1",
|
||||
"inverter-2"]
|
||||
and _blob["setpoint_entity"] == ""
|
||||
and _blob["pv_entities"] == ["sensor.pv_a", "sensor.pv_b"]
|
||||
and _blob["pv_invert_entities"] == ["sensor.pv_b"]
|
||||
and json.loads(_blob["static_profile"])["periods"][0]["eur_per_kwh"]
|
||||
== 0.34 and _blob["price_taxes_eur_kwh"]
|
||||
== U.REGION_PRESETS["flanders"]["taxes_eur_kwh"]
|
||||
and _blob["peak_cap_w"] == 4200 and _blob["shadow_mode"] == "on")
|
||||
check("every key config.yaml declares is written, in the stored order",
|
||||
list(_blob)[:len(NEW)] == list(NEW) and set(_blob) == set(NEW))
|
||||
check("the wizard saved without a restart of its own: the restart is a "
|
||||
"second, confirmed request", _sup.restarts == 0
|
||||
and 'JSON.stringify({ confirm: "restart" })' in JS.split(
|
||||
"async function finish")[1])
|
||||
_all = B.blobs()
|
||||
_new = [(n, r) for n, _b, r in _all if n.startswith("new-")]
|
||||
_ok = [n for n, _b, r in _all if not r]
|
||||
print(f" {len(_all)} blobs from {len(B.starts())} starts; "
|
||||
f"{len(_ok)} accepted")
|
||||
check(f"every plan from a new install is accepted by the shipped validator "
|
||||
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
|
||||
if r])
|
||||
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
|
||||
check(f"every plan from a new install as shipped (no power request entity, "
|
||||
f"setup-only mode) is accepted ({len(_empty)} plans)",
|
||||
len(_empty) == len(_new) and not [n for n, r in _empty if r])
|
||||
check("every plan from the TEST-23 sites is accepted, except where the site "
|
||||
"trades on price with a plan that has no meter or no prices, which the "
|
||||
"last step then lists",
|
||||
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
|
||||
for n, _b, r in _all) and len(_ok) >= 100)
|
||||
check("every accepted blob keeps every option key (the Supervisor count, 78)",
|
||||
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n7. a stale save names only what changed elsewhere (P2 finding a)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def _stale(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
sup.options["peak_cap_w"] = 5000 # the Configuration tab
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["price_timeout_s"] = 12 # the operator's own edit
|
||||
first = await _post(s, root + "config/save",
|
||||
{"model": model, "base": m["ui"],
|
||||
"fingerprint": m["fingerprint"]})
|
||||
again = await _post(s, root + "config/save",
|
||||
{"model": model, "base": m["ui"],
|
||||
"fingerprint": first[1]["fingerprint"]})
|
||||
return first, again
|
||||
|
||||
|
||||
(_first_r, _again), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
|
||||
_stale))
|
||||
print(f" changed_fields {_first_r[1]['changed_fields']}, moved "
|
||||
f"{_first_r[1]['moved']}, changed {_first_r[1]['changed']}")
|
||||
check("the banner's list is the other surface's key only, not the operator's "
|
||||
"own edit", _first_r[0] == 409
|
||||
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
|
||||
and _first_r[1]["moved"] == ["peak_cap_w"])
|
||||
check("`changed` (stored against what this page would write) is only the "
|
||||
"page's own edit, since a save writes only that (UX-15 F2)",
|
||||
_first_r[1]["changed"] == ["price_timeout_s"])
|
||||
check("saving again with the new token is the deliberate second press: it "
|
||||
"stores this page's own edit and keeps the other surface's change "
|
||||
"(UX-15 F2)",
|
||||
_again[0] == 200 and _sup.options["peak_cap_w"] == 5000
|
||||
and _sup.options["price_timeout_s"] == 12)
|
||||
|
||||
|
||||
async def _two_pages(s, root, sup):
|
||||
"""The classic page and this one open the same blob; the classic one
|
||||
saves first (the browser run found the token was dropped then)."""
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
classic = await _get(s, root + "config/model?lang=en")
|
||||
theirs = dict(classic["options"], peak_cap_w=5000)
|
||||
await _post(s, root + "config/save", {"options": theirs,
|
||||
"fingerprint": classic["fingerprint"]})
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["price_timeout_s"] = 12
|
||||
return await _post(s, root + "config/save",
|
||||
{"model": model, "fingerprint": m["fingerprint"]})
|
||||
|
||||
|
||||
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _two_pages))
|
||||
check("two pages on one blob: after the other page saved, this one's banner "
|
||||
"still names only the other page's change",
|
||||
_r[0] == 409 and _r[1]["changed_fields"] == ["peak_cap_w"])
|
||||
|
||||
|
||||
async def _unknown(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
sup.options["peak_cap_w"] = 5000
|
||||
model = copy.deepcopy(m["ui"])
|
||||
model["price_timeout_s"] = 12
|
||||
return await _post(s, root + "config/save",
|
||||
{"model": model, "fingerprint": "not-handed-out"})
|
||||
|
||||
|
||||
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _unknown))
|
||||
check("a token the add-on did not hand out (it restarted) falls back to "
|
||||
"every key that differs, as before",
|
||||
_r[0] == 409 and _r[1]["moved"] is None
|
||||
and _r[1]["changed_fields"] == ["peak_cap_w", "price_timeout_s"])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n8. the wizard'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
|
||||
|
||||
|
||||
def _sentences(t):
|
||||
return len(re.findall(r"[.!?](?:\s|$)", t))
|
||||
|
||||
|
||||
_long = [(p, t) for p, t in _texts(WS)
|
||||
if len(t.split()) > (25 if p.endswith("description") else 6)
|
||||
or (p.endswith("description") and _sentences(t) > 1) or ";" in t]
|
||||
check(f"every wizard string is one sentence of at most 25 words, or a name "
|
||||
f"of at most 6 ({_long})", not _long and len(list(_texts(WS))) > 50)
|
||||
_rendered = [x for notes in (
|
||||
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
|
||||
setup_checks(SITE, entities=None), setup_checks(_hw, entities=ROWS),
|
||||
setup_checks(_fixed, entities=ROWS), setup_checks(SITE, entities=_rev),
|
||||
setup_checks(dict(
|
||||
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
|
||||
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
|
||||
for x in (n.text for n in notes)]
|
||||
_over = [t for t in _rendered if len(t.split()) > 25 or _sentences(t) > 1
|
||||
or ";" in t]
|
||||
check(f"every live check, rendered with real values, is one sentence of at "
|
||||
f"most 25 words ({len(_rendered)} rendered, over: {_over})",
|
||||
len(_rendered) > 20 and not _over)
|
||||
_dialogs = [WS["dialog"]["description"], WS["dialog_live"]["description"]]
|
||||
check("both restart dialogs name the 0 W, and the shadow one says it stays",
|
||||
all("0 W" in d for d in _dialogs) and "shadow mode keeps them at 0 W"
|
||||
in _dialogs[0])
|
||||
check("the wizard defaults to shadow mode: the switch starts on",
|
||||
"shadowOn: true" in JS)
|
||||
check("the shadow switch never writes into V, which the Installer view saves "
|
||||
"too: it goes only on the model the wizard posts (review R3)",
|
||||
"V[M.setup.shadow.field]" not in JS and "V[s.field]" not in JS
|
||||
and JS.count("model: posted()") == 2)
|
||||
check("a skip after a fall keeps saying the reading went down (review R2)",
|
||||
"wrong: !!k.wrong }; drawStep();" in JS
|
||||
and "WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped" in JS
|
||||
and 'const wrong = o.say === "none" ? !!k.wrong : o.sets == null;' in JS)
|
||||
_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
|
||||
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
|
||||
check(f"settings.js, wizard included, names no option key ({_leak})",
|
||||
not _leak)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n9. UX-15: F1, F2, a missing meter entity, and step 1 after a skip")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def _f1(s, root, sup):
|
||||
m = await _get(s, root + "config/model?lang=en")
|
||||
price = copy.deepcopy(m["ui"])
|
||||
price.update({"ui.contract": "daynight", "ui.fallback": "daynight",
|
||||
"ui.day_price": None, "ui.night_price": 0.25})
|
||||
none = copy.deepcopy(m["ui"])
|
||||
none["inverters"] = []
|
||||
none[U.COUNT] = 0
|
||||
return {"price": (await _post(s, root + "config/validate",
|
||||
{"model": price, "setup": True}))[1],
|
||||
"none": (await _post(s, root + "config/validate",
|
||||
{"model": none}))[1],
|
||||
"save": await _post(s, root + "config/save", {
|
||||
"model": price, "base": m["ui"], "setup": "done",
|
||||
"fingerprint": m["fingerprint"]})}
|
||||
|
||||
|
||||
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
|
||||
SITE, _f1))
|
||||
print(f" empty day price: steps {_r['price'].get('steps')}")
|
||||
check("F1: a price the page cannot turn into options blocks step 4, beside "
|
||||
"its own field",
|
||||
_r["price"].get("steps", {}).get("4", 0) >= 1
|
||||
and {"field": "ui.day_price", "card": None, "tier": "bad",
|
||||
"text": WS_D["no_price"]["description"]} in _r["price"].get("messages", []))
|
||||
check("F1: ...and the last step's live checks still run on the rest",
|
||||
[x["tier"] for x in _r["price"].get("setup", [])
|
||||
if "number.inv1_power" in x["text"]] == ["ok"]
|
||||
and any("402 W" in x["text"] for x in _r["price"]["setup"]))
|
||||
check("F1: no inverter card blocks step 2, not the last step alone",
|
||||
_r["none"].get("steps", {}).get("2", 0) >= 1)
|
||||
check("F1: the save refuses it (422) and says which step to go back to",
|
||||
_r["save"][0] == 422 and _r["save"][1]["steps"]["4"] >= 1
|
||||
and _sup.writes == [])
|
||||
|
||||
|
||||
async def _tabs(s, root, sup):
|
||||
"""Two tabs open one blob. A saves a peak limit; B edited only the
|
||||
timeout, gets the 409, and presses Save again."""
|
||||
a = await _get(s, root + "config/model?lang=en")
|
||||
b = await _get(s, root + "config/model?lang=en")
|
||||
va = copy.deepcopy(a["ui"])
|
||||
va["peak_cap_w"] = 4000
|
||||
ra = await _post(s, root + "config/save", {
|
||||
"model": va, "base": a["ui"], "fingerprint": a["fingerprint"]})
|
||||
after_a = copy.deepcopy(sup.options)
|
||||
vb = copy.deepcopy(b["ui"])
|
||||
vb["price_timeout_s"] = 12
|
||||
first = await _post(s, root + "config/save", {
|
||||
"model": vb, "base": b["ui"], "fingerprint": b["fingerprint"]})
|
||||
again = await _post(s, root + "config/save", {
|
||||
"model": vb, "base": b["ui"], "fingerprint": first[1]["fingerprint"]})
|
||||
return ra, after_a, first, again
|
||||
|
||||
|
||||
(_ra, _after_a, _first_b, _again_b), _sup = asyncio.run(
|
||||
session(Controller(SITE, STATES), dict(SITE, peak_cap_w=3600), _tabs))
|
||||
_moved = sorted(k for k in _sup.options
|
||||
if _sup.options[k] != _after_a.get(k))
|
||||
print(f" A: {_ra[0]}, B: {_first_b[0]} then {_again_b[0]}; B's re-save "
|
||||
f"moved {_moved}")
|
||||
check("F2: two tabs. A saves peak 4.0; B, which edited only the timeout, "
|
||||
"gets the 409 naming the peak limit",
|
||||
_ra[0] == 200 and _first_b[0] == 409
|
||||
and _first_b[1]["changed_fields"] == ["peak_cap_w"])
|
||||
check("F2: B's second press keeps A's peak 4.0 and writes only B's own key",
|
||||
_again_b[0] == 200 and _sup.options["peak_cap_w"] == 4000
|
||||
and _sup.options["price_timeout_s"] == 12
|
||||
and _moved == ["price_timeout_s"])
|
||||
_both = uipage.only_edits({"a": 2, "b": 1, "c": 5}, {"a": 1, "b": 1, "c": 3},
|
||||
{"a": 1, "b": 9, "c": 4, "d": 0})
|
||||
check("F2: only_edits() keeps every stored key the page did not edit, and a "
|
||||
"key both edited takes this page's value (the deliberate press)",
|
||||
_both == {"a": 2, "b": 9, "c": 5, "d": 0}
|
||||
and list(_both) == ["a", "b", "c", "d"])
|
||||
|
||||
_gone = tiers(setup_checks(dict(SITE, meter_entity="sensor.gone"),
|
||||
entities=ROWS))
|
||||
check("a meter entity Home Assistant does not have is red on the last step, "
|
||||
"so the wizard does not save it with the kettle test skipped",
|
||||
("bad", "Home Assistant has no entity sensor.gone, which “Grid power "
|
||||
"entity” (meter_entity) names.") in _gone)
|
||||
_net_gone = tiers(setup_checks(dict(SITE, meter_source="ha_signed",
|
||||
p1_net_entity="sensor.gone"),
|
||||
entities=ROWS))
|
||||
check("...for a net or import and export meter too",
|
||||
[t for t, x in _net_gone if "sensor.gone" in x] == ["bad"])
|
||||
from app.validate import config_notes # noqa: E402
|
||||
check("...while the Installer view keeps it a warning, which never blocks "
|
||||
"a save there",
|
||||
[n.tier for n in config_notes(dict(SITE, meter_entity="sensor.gone"),
|
||||
entities=ROWS)
|
||||
if "sensor.gone" in n.text] == ["warn"])
|
||||
|
||||
check("step 1 after a skip that followed a fall says plainly that the "
|
||||
"reading went down",
|
||||
'k.say === "skipped" && k.wrong ? "skipped_down" : k.say' in JS
|
||||
and "went down" in WS["kettle"]["skipped_down"]["description"]
|
||||
and "not tested" not in WS["kettle"]["skipped_down"]["description"])
|
||||
check("in setup-only mode the wizard's restart dialog does not claim a 0 W "
|
||||
"wind-down that nothing runs, and the shadow one says 0 W",
|
||||
"M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live)" in JS
|
||||
and "0 W" in WS["dialog_setup"]["description"]
|
||||
and "0 W" not in WS["dialog_setup_live"]["description"])
|
||||
check("the page posts the model it opened with every save (F2)",
|
||||
"model: V, base: ORIG, fingerprint: FP" in JS)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,494 @@
|
||||
"""UX-15: setup-only mode. `python3 test_setup_only.py`
|
||||
|
||||
A new install names no inverter, and instead of exiting 78 it serves the setup
|
||||
wizard and commands nothing (DECISIONS/UX-15-setup-only-mode.md).
|
||||
Part 1 checks when it applies. Part 2 the write path refuses in that mode.
|
||||
Part 3 boots the REAL `app.main.main()` in a child process (this file with
|
||||
`--driver`) against a fake Supervisor and Home Assistant over HTTP, and logs
|
||||
every request they get: the shipped defaults serve the wizard with no service
|
||||
call, also with `auto_start` on, a Store that says control was started and a
|
||||
Start pressed on the page; the wizard saves; the next boot passes the normal
|
||||
gate and runs in shadow. Part 4: a refused but complete configuration still
|
||||
exits 78 and serves nothing.
|
||||
Takes ~25 s. Mutating a module this file grades? Read MUTATION.md first.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
DEADLINE_S = 60
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def defaults():
|
||||
import ux13_demo as D
|
||||
return D.defaults(D.CONFIG)
|
||||
|
||||
|
||||
def state(eid, value, unit="W", cls=None, **attrs):
|
||||
return {"entity_id": eid, "state": value,
|
||||
"attributes": dict({"friendly_name": eid,
|
||||
"unit_of_measurement": unit,
|
||||
"device_class": cls}, **attrs)}
|
||||
|
||||
|
||||
STATES = [state("sensor.grid", "402"),
|
||||
state("number.inv1_power", "0", min=-2500, max=2500, step=1),
|
||||
state("sensor.inv1_level", "62", "%", "battery"),
|
||||
state("sensor.inv1_battery", "0")]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The child: the real main(), with only the outside world faked over HTTP.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def driver(scenario, work):
|
||||
"""`work` holds options.json, state.json and the files the parent reads.
|
||||
Every request the fakes get is streamed as `EV [...]` lines."""
|
||||
import aiohttp
|
||||
from aiohttp import web as aioweb
|
||||
from aiohttp.abc import AbstractResolver
|
||||
import app.main as M
|
||||
import app.store
|
||||
import app.web
|
||||
import ux13_demo as D
|
||||
import ux13_wizard_blobs as B
|
||||
import translations_lint as TL
|
||||
|
||||
def event(*e):
|
||||
print("EV " + json.dumps(list(e)), flush=True)
|
||||
|
||||
opts = json.load(open(os.path.join(work, "options.json")))
|
||||
stored = {"options": copy.deepcopy(opts)}
|
||||
schema = D.schema_ui(D.CONFIG)
|
||||
translations = {"en": TL.parse_translation_file(
|
||||
os.path.join(HERE, "translations", "en.yaml"))}
|
||||
|
||||
# --- the fake Supervisor and Home Assistant ---------------------------
|
||||
async def h_probe(_r):
|
||||
event("GET", "/core/api/")
|
||||
return aioweb.Response(text="API running.")
|
||||
|
||||
async def h_states(_r):
|
||||
event("GET", "/core/api/states")
|
||||
return aioweb.json_response(STATES)
|
||||
|
||||
async def h_state(r):
|
||||
eid = r.match_info["eid"]
|
||||
event("GET", "/core/api/states/" + eid)
|
||||
found = next((s for s in STATES if s["entity_id"] == eid), None)
|
||||
return (aioweb.json_response(found) if found
|
||||
else aioweb.json_response({"message": "not found"}, status=404))
|
||||
|
||||
async def h_service(r):
|
||||
body = await r.json()
|
||||
event("SERVICE", r.match_info["domain"], r.match_info["service"], body)
|
||||
return aioweb.json_response([])
|
||||
|
||||
async def h_mqtt(_r):
|
||||
event("GET", "/services/mqtt")
|
||||
return aioweb.json_response({"result": "error"}, status=400)
|
||||
|
||||
async def h_info(_r):
|
||||
return aioweb.json_response({"data": {"slug": "local_goodwe",
|
||||
"homeassistant": "2026.9.1"}})
|
||||
|
||||
async def h_self_info(_r):
|
||||
return aioweb.json_response({"data": {
|
||||
"slug": "local_goodwe", "options": stored["options"],
|
||||
"schema": schema, "translations": translations}})
|
||||
|
||||
async def h_options(r):
|
||||
body = await r.json()
|
||||
stored["options"] = body["options"]
|
||||
event("OPTIONS")
|
||||
with open(os.path.join(work, "saved.json"), "w") as f:
|
||||
json.dump(body["options"], f)
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_restart(_r):
|
||||
event("RESTART")
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_other(r):
|
||||
event("OTHER", r.method, r.path)
|
||||
return aioweb.json_response({}, status=404)
|
||||
|
||||
fake = aioweb.Application()
|
||||
fake.add_routes([
|
||||
aioweb.get("/core/api/", h_probe),
|
||||
aioweb.get("/core/api/states", h_states),
|
||||
aioweb.get("/core/api/states/{eid}", h_state),
|
||||
aioweb.post("/core/api/services/{domain}/{service}", h_service),
|
||||
aioweb.get("/services/mqtt", h_mqtt),
|
||||
aioweb.get("/info", h_info),
|
||||
aioweb.get("/addons/self/info", h_self_info),
|
||||
aioweb.post("/addons/self/options", h_options),
|
||||
aioweb.post("/addons/self/restart", h_restart),
|
||||
aioweb.route("*", "/{tail:.*}", h_other)])
|
||||
|
||||
fake_port = free_port()
|
||||
|
||||
class ToFake(AbstractResolver):
|
||||
"""`http://supervisor` is the fake, on its port."""
|
||||
async def resolve(self, host, port=0, family=socket.AF_INET):
|
||||
return [{"hostname": host, "host": "127.0.0.1", "port": fake_port,
|
||||
"family": socket.AF_INET, "proto": 0, "flags": 0}]
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
Real = aiohttp.ClientSession
|
||||
|
||||
def session(*a, **k):
|
||||
k.setdefault("connector", aiohttp.TCPConnector(resolver=ToFake()))
|
||||
return Real(*a, **k)
|
||||
|
||||
aiohttp.ClientSession = session
|
||||
os.environ["SUPERVISOR_TOKEN"] = "test-token"
|
||||
M.OPTIONS_PATH = os.path.join(work, "options.json")
|
||||
app.store.STATE_PATH = os.path.join(work, "state.json")
|
||||
M.INGRESS_PORT = free_port()
|
||||
kw = M.Controller.__init__.__kwdefaults__
|
||||
kw["peak_db_path"] = os.path.join(work, "tariff.db")
|
||||
kw["price_history_path"] = os.path.join(work, "price_history.json")
|
||||
|
||||
served = []
|
||||
real_start = app.web.start
|
||||
|
||||
async def start(controller, port=8099, selfcfg=None):
|
||||
runner = await real_start(controller, port, selfcfg)
|
||||
served.append(type(controller).__name__) # listening now
|
||||
event("SERVED", type(controller).__name__,
|
||||
getattr(controller.hass, "read_only", None))
|
||||
return runner
|
||||
|
||||
app.web.start = start
|
||||
real_amain = M.amain
|
||||
root = f"http://127.0.0.1:{M.INGRESS_PORT}/"
|
||||
|
||||
async def ask(s, method, path, body=None):
|
||||
async with s.request(method, root + path, json=body) as r:
|
||||
try:
|
||||
out = await r.json(content_type=None)
|
||||
except ValueError:
|
||||
out = None
|
||||
event("PAGE", method, path, r.status, out)
|
||||
return r.status, out
|
||||
|
||||
async def scenario_steps():
|
||||
async with Real() as s:
|
||||
await ask(s, "GET", "status")
|
||||
_st, model = await ask(s, "GET", "config/model?lang=en")
|
||||
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
|
||||
await ask(s, "POST", "action", {"action": "auto_toggle"})
|
||||
if scenario.startswith("setup"):
|
||||
await ask(s, "POST", "action", {"action": "maint_start"})
|
||||
wiz = B.apply(model["ui"], B.BASE)
|
||||
wiz = B.browser(json.loads(json.dumps(wiz)))
|
||||
await ask(s, "POST", "config/validate",
|
||||
{"model": wiz, "setup": True})
|
||||
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
|
||||
await ask(s, "POST", "config/save",
|
||||
{"model": wiz, "base": model["ui"],
|
||||
"fingerprint": model["fingerprint"],
|
||||
"setup": "done"})
|
||||
await ask(s, "POST", "config/restart", {"confirm": "restart"})
|
||||
await asyncio.sleep(3.0) # room for any stray task to write
|
||||
await ask(s, "GET", "status")
|
||||
|
||||
async def wrapped():
|
||||
runner = aioweb.AppRunner(fake)
|
||||
await runner.setup()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", fake_port).start()
|
||||
task = asyncio.create_task(real_amain())
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
while not served and not task.done() \
|
||||
and time.monotonic() - t0 < 20:
|
||||
await asyncio.sleep(0.05)
|
||||
if task.done():
|
||||
event("ENDED_BEFORE_SERVING")
|
||||
await task
|
||||
return
|
||||
await scenario_steps()
|
||||
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
|
||||
await asyncio.wait_for(task, 20)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
M.amain = wrapped
|
||||
M.main()
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def run_child(scenario, options, state=None):
|
||||
work = tempfile.mkdtemp(prefix="ux15-")
|
||||
with open(os.path.join(work, "options.json"), "w") as f:
|
||||
json.dump(options, f)
|
||||
if state is not None:
|
||||
with open(os.path.join(work, "state.json"), "w") as f:
|
||||
json.dump(state, f)
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
try:
|
||||
p = subprocess.run([sys.executable, "-B", __file__, "--driver",
|
||||
scenario, work], cwd=HERE, env=env,
|
||||
capture_output=True, text=True, timeout=DEADLINE_S)
|
||||
rc, out, err = p.returncode, p.stdout, p.stderr
|
||||
except subprocess.TimeoutExpired as e:
|
||||
rc, out, err = None, e.stdout or "", e.stderr or ""
|
||||
out = out.decode() if isinstance(out, bytes) else out
|
||||
err = err.decode() if isinstance(err, bytes) else err
|
||||
ev = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
|
||||
files = {}
|
||||
for name in ("saved.json", "state.json"):
|
||||
path = os.path.join(work, name)
|
||||
if os.path.exists(path):
|
||||
files[name] = json.load(open(path))
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
return rc, ev, err, files
|
||||
|
||||
|
||||
def page(ev, method, path):
|
||||
"""(status, body) of each page request, in order."""
|
||||
return [(e[3], e[4]) for e in ev
|
||||
if e[0] == "PAGE" and e[1] == method and e[2].startswith(path)]
|
||||
|
||||
|
||||
def tail(text, n=12):
|
||||
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
|
||||
|
||||
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--driver":
|
||||
driver(sys.argv[2], sys.argv[3])
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. when setup-only mode applies")
|
||||
# --------------------------------------------------------------------------- #
|
||||
from app import setuponly # noqa: E402
|
||||
from app.validate import validate_startup_config # noqa: E402
|
||||
|
||||
SHIPPED = defaults()
|
||||
V = validate_startup_config
|
||||
check("the shipped defaults are refused only for the missing power request "
|
||||
"entity, and that is setup-only mode",
|
||||
setuponly.applies(SHIPPED, V(SHIPPED)) and len(V(SHIPPED)) == 1)
|
||||
_blank_bad = dict(SHIPPED, maintenance_charge_w=2500)
|
||||
check("no power request entity AND another refusal: not setup-only, exit 78",
|
||||
len(V(_blank_bad)) >= 2 and not setuponly.applies(_blank_bad,
|
||||
V(_blank_bad)))
|
||||
_complete_bad = dict(SHIPPED, setpoint_entity="number.inv1_power",
|
||||
maintenance_charge_w=2500)
|
||||
check("a complete configuration that is refused: not setup-only, exit 78",
|
||||
V(_complete_bad) and not setuponly.applies(_complete_bad,
|
||||
V(_complete_bad)))
|
||||
_list_blank = dict(SHIPPED, inverters=[{
|
||||
"name": "inverter-a", "setpoint_entity": "", "soc_entity": "sensor.s",
|
||||
"batt_entity": "sensor.b", "max_w": 2000, "bms_soc_floor": 10}])
|
||||
check("an inverter list whose entry has no power request entity: not "
|
||||
"setup-only (someone edited it), exit 78",
|
||||
V(_list_blank) and not setuponly.applies(_list_blank, V(_list_blank)))
|
||||
_ok = dict(SHIPPED, setpoint_entity="number.inv1_power")
|
||||
check("a startable configuration: nothing refused, the normal start",
|
||||
V(_ok) == [] and not setuponly.applies(_ok, V(_ok)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. in setup-only mode the write path refuses")
|
||||
# --------------------------------------------------------------------------- #
|
||||
from app import hass as H # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
|
||||
|
||||
class Session:
|
||||
"""Records every request a HomeAssistant would send."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def post(self, url, **k):
|
||||
self.sent.append(("POST", url))
|
||||
raise AssertionError("a request was sent")
|
||||
|
||||
def get(self, url, **k):
|
||||
self.sent.append(("GET", url))
|
||||
raise AssertionError("a request was sent")
|
||||
|
||||
|
||||
def _refused(coro):
|
||||
"""True only for the read-only refusal; a request that reached the
|
||||
session (AssertionError there) or no refusal at all is False."""
|
||||
try:
|
||||
asyncio.run(coro)
|
||||
except H.ReadOnly:
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
_s = Session()
|
||||
_ro = H.HomeAssistant(_s, "t", read_only=True)
|
||||
check("a read-only HomeAssistant refuses number.set_value before it sends "
|
||||
"anything", _refused(_ro.set_number("number.inv1_power", 0.0))
|
||||
and _s.sent == [])
|
||||
check("...and any other service call, e.g. a notification",
|
||||
_refused(_ro.call("persistent_notification", "create",
|
||||
{"message": "x"})) and _s.sent == [])
|
||||
try:
|
||||
_ro.read_only = False
|
||||
_flipped = True
|
||||
except AttributeError:
|
||||
_flipped = False
|
||||
check("...and read-only cannot be switched off afterwards",
|
||||
not _flipped and _ro.read_only is True)
|
||||
_rw = H.HomeAssistant(Session(), "t")
|
||||
try:
|
||||
asyncio.run(_rw.set_number("number.inv1_power", 0.0))
|
||||
_sent = False
|
||||
except AssertionError:
|
||||
_sent = True
|
||||
check("control: a normal HomeAssistant does send the write", _sent
|
||||
and _rw.read_only is False)
|
||||
try:
|
||||
M.Controller(dict(_ok), _ro, None, None, stop=asyncio.Event())
|
||||
_built = True
|
||||
except ValueError as err:
|
||||
_built = "read-only" not in str(err)
|
||||
except Exception: # noqa: BLE001
|
||||
_built = True # it got past the check and failed on something else
|
||||
check("no controller can be built on a read-only HomeAssistant", not _built)
|
||||
try:
|
||||
setuponly.SetupOnly(SHIPPED, _rw, None)
|
||||
_took = True
|
||||
except ValueError:
|
||||
_took = False
|
||||
check("setup-only mode refuses a HomeAssistant that can write", not _took)
|
||||
_so = setuponly.SetupOnly(SHIPPED, _ro, None)
|
||||
check("setup-only mode holds no inverter and never says control is started",
|
||||
list(_so.inverters) == [] and _so.auto is False
|
||||
and _so.status_payload()["setup_only"] is True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. the real main(): the shipped defaults serve the wizard, 0 writes")
|
||||
# --------------------------------------------------------------------------- #
|
||||
_runs = {
|
||||
"setup": (dict(SHIPPED), None),
|
||||
# A started-control attempt three ways: auto_start on, a Store that says
|
||||
# control was started, and the page's own Start (the driver presses it).
|
||||
"setup_started": (dict(SHIPPED, auto_start=True), {"auto": True}),
|
||||
}
|
||||
SAVED = None
|
||||
for name, (options, st) in _runs.items():
|
||||
rc, ev, err, files = run_child(name, options, st)
|
||||
services = [e for e in ev if e[0] == "SERVICE"]
|
||||
print(f" {name}: exit {rc}, {len(ev)} requests logged, "
|
||||
f"{len(services)} service calls")
|
||||
check(f"{name}: the process served the page in setup-only mode on a "
|
||||
"read-only HomeAssistant, and exited 0 when stopped",
|
||||
rc == 0 and ["SERVED", "SetupOnly", True] in ev)
|
||||
if rc != 0:
|
||||
print(tail(err))
|
||||
check(f"{name}: 0 service calls to Home Assistant (no set_value, no "
|
||||
"notification), and MQTT was never asked for",
|
||||
services == [] and ["GET", "/services/mqtt"] not in ev)
|
||||
_status = page(ev, "GET", "status")
|
||||
check(f"{name}: /status says setup_only, with the setup banner",
|
||||
_status and all(b and b.get("setup_only") is True
|
||||
and b.get("mode") == "setup_only"
|
||||
and b.get("banner", "").startswith("Setup")
|
||||
for _c, b in _status))
|
||||
_model = page(ev, "GET", "config/model")
|
||||
check(f"{name}: the page model opens the wizard (no power request) and "
|
||||
"says setup-only",
|
||||
_model and _model[0][1]["setup_needed"] == "no_power_request"
|
||||
and _model[0][1]["setup_only"] is True)
|
||||
_acts = page(ev, "POST", "action")
|
||||
check(f"{name}: Start and the battery cycle are refused with 409",
|
||||
len(_acts) == 2 and all(c == 409 for c, _b in _acts))
|
||||
check(f"{name}: setup-only mode writes no Store file before the wizard "
|
||||
"saves, so the first-run rule still sees a new install",
|
||||
[e for e in ev if e[0] == "STATE_FILE"][:2]
|
||||
== [["STATE_FILE", st is not None]] * 2)
|
||||
_val = page(ev, "POST", "config/validate")
|
||||
check(f"{name}: the wizard's live checks run and find nothing red",
|
||||
_val and _val[0][0] == 200
|
||||
and not [m for m in _val[0][1]["messages"] if m["tier"] == "bad"]
|
||||
and not [m for m in _val[0][1]["setup"] if m["tier"] == "bad"])
|
||||
_save = page(ev, "POST", "config/save")
|
||||
check(f"{name}: the wizard saves (200) and asks for the restart",
|
||||
_save and _save[0][0] == 200 and ["RESTART"] in ev
|
||||
and "saved.json" in files)
|
||||
check(f"{name}: the Store gets setup_done from the save",
|
||||
bool(files.get("state.json", {}).get("setup_done")))
|
||||
if name == "setup":
|
||||
SAVED, SAVED_STATE = files.get("saved.json"), files.get("state.json")
|
||||
|
||||
check("the saved blob passes the normal gate: no refusal, not setup-only",
|
||||
SAVED is not None and V(SAVED) == []
|
||||
and not setuponly.applies(SAVED, V(SAVED))
|
||||
and SAVED.get("shadow_mode") == "on")
|
||||
|
||||
print("\n after the restart, on the saved settings:")
|
||||
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
|
||||
services = [e for e in ev if e[0] == "SERVICE"]
|
||||
writes = [e[3].get("value") for e in services
|
||||
if e[1:3] == ["number", "set_value"]]
|
||||
print(f" after: exit {rc}, {len(services)} service calls, set_value "
|
||||
f"values {sorted(set(writes))}")
|
||||
check("the next boot passes the normal gate: a Controller serves the page, "
|
||||
"and it exits 0 when stopped", rc == 0 and ["SERVED", "Controller",
|
||||
False] in ev)
|
||||
if rc != 0:
|
||||
print(tail(err))
|
||||
check("...where Start is accepted (200), unlike in setup-only mode",
|
||||
[c for c, _b in page(ev, "POST", "action")] == [200])
|
||||
_status = page(ev, "GET", "status")
|
||||
check("...its /status is not setup-only", _status and all(
|
||||
b and "setup_only" not in b for _c, b in _status))
|
||||
_model = page(ev, "GET", "config/model")
|
||||
check("...and the wizard does not open again: setup_done is in the Store",
|
||||
_model and _model[0][1]["setup_needed"] is None
|
||||
and _model[0][1]["setup_only"] is False)
|
||||
check("...and it runs in shadow, even after Start: every service call is "
|
||||
"number.set_value 0 W", writes and len(writes) == len(services)
|
||||
and all(w == 0.0 for w in writes))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a refused configuration that is not only missing setup: exit 78")
|
||||
# --------------------------------------------------------------------------- #
|
||||
for name, options in (("refused", _complete_bad), ("refused_blank", _blank_bad)):
|
||||
rc, ev, err, _files = run_child(name, options)
|
||||
print(f" {name}: exit {rc}")
|
||||
check(f"{name}: exit 78 and nothing served, no service call",
|
||||
rc == 78 and not [e for e in ev if e[0] in ("SERVED", "SERVICE")]
|
||||
and ["ENDED_BEFORE_SERVING"] in ev)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,205 @@
|
||||
"""UX-13 P0: the layout table places every option once. `python3 test_uilayout.py`
|
||||
|
||||
app/uilayout.py is the one place UI code names an option key
|
||||
(DECISIONS/UX-13-one-layout-table.md). This file fails CI when config.yaml
|
||||
gains an option the table does not place, and checks each row against the
|
||||
sources UX-02 keeps authoritative: config.yaml for ranges, translations and
|
||||
app/ui_text for words, validate.py for which entities are read as watts.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
from app import uilayout as L, uimodel, validate # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
|
||||
ORDER, NESTED = TL.config_surface(CONFIG)
|
||||
TYPES = dict(re.findall(r"^ ([a-z_0-9]+): *(\S.*?) *$",
|
||||
CONFIG[CONFIG.index("\nschema:"):], re.M))
|
||||
EN = TL.parse_translation_file(os.path.join(HERE, "translations", "en.yaml"))
|
||||
UI_TEXT = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))
|
||||
ROWS = list(L.fields())
|
||||
KEYS = [f.key for _g, _p, f in ROWS]
|
||||
|
||||
|
||||
def coverage_problems(order, nested):
|
||||
"""Every way the table and config.yaml's schema disagree."""
|
||||
out = []
|
||||
placed = [k for k in KEYS if not k.startswith(("ui.", "inverters."))]
|
||||
for k in order:
|
||||
n = placed.count(k) + (k in L.DERIVED) + (k in L.HIDDEN)
|
||||
if n != 1:
|
||||
out.append(f"{k} is placed {n} times")
|
||||
out += [f"{k} is not in config.yaml" for k in
|
||||
set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order]
|
||||
sub = [k.split(".", 1)[1] for k in KEYS if k.startswith("inverters.")]
|
||||
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
|
||||
if sub.count(k) != 1]
|
||||
out += [f"inverters.{k} is not in config.yaml" for k in sub
|
||||
if k not in nested.get("inverters", [])]
|
||||
return out
|
||||
|
||||
|
||||
print("1. every option has exactly one place")
|
||||
_p = coverage_problems(ORDER, NESTED)
|
||||
_fields = sum(1 for k in ORDER if k in KEYS)
|
||||
print(f" {len(ORDER)} options: {_fields} fields, {len(L.DERIVED)} derived, "
|
||||
f"{len(L.HIDDEN)} hidden. Problems: {_p}")
|
||||
check(f"config.yaml's {len(ORDER)} options are each a field, derived or hidden, "
|
||||
"once", not _p and len(ORDER) >= 78)
|
||||
check("a new option without a place fails this check",
|
||||
coverage_problems(ORDER + ["new_option_w"], NESTED)
|
||||
== ["new_option_w is placed 0 times"])
|
||||
check("...and so does a new inverter field",
|
||||
coverage_problems(ORDER, {"inverters": NESTED["inverters"] + ["mode"]})
|
||||
== ["inverters.mode has no card field"])
|
||||
check("no key is both a field and derived",
|
||||
not set(KEYS) & (set(L.DERIVED) | set(L.HIDDEN)))
|
||||
check("each derived key names the page field that writes it",
|
||||
all(any(f.key == how for _g, _p2, f in ROWS)
|
||||
for how in L.DERIVED.values()))
|
||||
|
||||
print("\n2. each row is well formed")
|
||||
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) | {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)
|
||||
for _g, _p2, f in ROWS))
|
||||
check("everything applies after a restart: options are read at start, and "
|
||||
"OPT-11's runtime switches are not built yet",
|
||||
all(f.applies == L.RESTART for _g, _p2, f in ROWS))
|
||||
check("no unit states a number, so no range is written here",
|
||||
all(not re.search(r"\d", f.unit) for _g, _p2, f in ROWS))
|
||||
|
||||
|
||||
_SCHEMA = CONFIG[CONFIG.index("\nschema:"):]
|
||||
_ROSTER = re.search(r"^ inverters:\n((?: {4,}.*\n)+)", _SCHEMA, re.M).group(1)
|
||||
ROSTER_TYPES = dict(re.findall(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S+)", _ROSTER,
|
||||
re.M))
|
||||
|
||||
|
||||
def _type(key):
|
||||
if key.startswith("inverters."):
|
||||
return ROSTER_TYPES.get(key.split(".", 1)[1], "")
|
||||
return TYPES.get(key, "")
|
||||
|
||||
|
||||
_ranged = [f.key for _g, _p2, f in ROWS
|
||||
if f.control in (L.SLIDER, L.NUMBER, L.LOCKED)
|
||||
and not f.key.startswith("ui.")]
|
||||
_unranged = [k for k in _ranged if not re.match(r"(int|float)\(", _type(k))]
|
||||
check(f"every slider, number and locked value has a range in config.yaml "
|
||||
f"({len(_ranged)} of them, without: {_unranged})", not _unranged)
|
||||
_SUFFIX = (("_eur_kwh", "€/kWh"), ("_eur", "€/kWh"), ("_kwh", "kWh"),
|
||||
("_w", "W"), ("_s", "s"), ("_h", "h"), ("_min", "min"),
|
||||
("_days", "days"), ("_floor", "%"), ("_ceiling", "%"),
|
||||
("_target", "%"))
|
||||
|
||||
|
||||
def _unit_problem(f):
|
||||
want = next((u for suf, u in _SUFFIX if f.key.endswith(suf)), None)
|
||||
if want is None or f.control not in (L.SLIDER, L.NUMBER, L.LOCKED):
|
||||
return False
|
||||
return f.unit != want and not (want == "W" and f.unit == "kW"
|
||||
and f.scale == 1000)
|
||||
|
||||
|
||||
_mism = [(f.key, f.unit) for _g, _p2, f in ROWS if _unit_problem(f)]
|
||||
check(f"each unit matches its key's suffix (mismatched: {_mism})", not _mism)
|
||||
check("the peak limit is shown in kW and stored in W",
|
||||
L.field("peak_cap_w").unit == "kW" and L.field("peak_cap_w").scale == 1000)
|
||||
_watt = sorted({f.key.split(".")[-1] for _g, _p2, f in ROWS
|
||||
if f.kind == "power_w"})
|
||||
check(f"the pickers that expect watts are exactly the entities validate.py "
|
||||
f"reads as watts ({_watt})",
|
||||
_watt == sorted(validate.WATT_ENTITY_KEYS))
|
||||
check("range_of() reads a Supervisor-shaped schema, nested ones too",
|
||||
L.range_of("max_w", [{"name": "max_w", "lengthMin": 100,
|
||||
"lengthMax": 5000}]) == (100, 5000)
|
||||
and L.range_of("inverters.max_w", [{"name": "inverters", "schema": [
|
||||
{"name": "max_w", "lengthMin": 100, "lengthMax": 5000}]}])
|
||||
== (100, 5000)
|
||||
and L.range_of("p1_host", [{"name": "p1_host"}]) is None)
|
||||
|
||||
print("\n3. the words come from translations and app/ui_text")
|
||||
_conf = EN["configuration"]
|
||||
_no_tr = [k for k in KEYS if not k.startswith("ui.") and not (
|
||||
k in _conf if "." not in k
|
||||
else k.split(".")[1] in _conf["inverters"]["fields"])]
|
||||
check(f"every option field has an en.yaml name (missing: {_no_tr})", not _no_tr)
|
||||
_no_ui = [k for k in KEYS if k.startswith("ui.")
|
||||
and k[3:] not in UI_TEXT["fields"]]
|
||||
check(f"every page-only field has words in ui_text (missing: {_no_ui})",
|
||||
not _no_ui)
|
||||
_titles = [g.id for g in L.GROUPS if g.id not in UI_TEXT["groups"]] + [
|
||||
f"{g.id}.{p.id}" for g in L.GROUPS for p in g.parts
|
||||
if p.id and f"{g.id}.{p.id}" not in UI_TEXT["parts"]]
|
||||
check(f"every group and part has a title (missing: {_titles})", not _titles)
|
||||
_contract = {"none", "fixed", "daynight", "dynamic", "custom"}
|
||||
check("every contract, fallback and region the model can hold has words",
|
||||
set(UI_TEXT["choices"]["contract"]) == _contract
|
||||
and set(UI_TEXT["choices"]["fallback"]) == _contract - {"dynamic"}
|
||||
and set(UI_TEXT["choices"]["region"])
|
||||
== set(uimodel.REGION_PRESETS) | {uimodel.OWN})
|
||||
check("both 'applies' labels have words",
|
||||
set(UI_TEXT["applies"]) == {L.RESTART, L.NOW})
|
||||
|
||||
print("\n4. visibility follows the page model")
|
||||
_m = uimodel.options_to_ui({"meter_source": "ha_signed", "meter_phases": 3,
|
||||
"inverters": [], "maintenance_enabled": False})
|
||||
check("a signed meter shows its net entity, and three phases its net list",
|
||||
L.visible(L.field("p1_net_entity"), _m)
|
||||
and L.visible(L.field("p1_phase_net_entities"), _m)
|
||||
and not L.visible(L.field("meter_entity"), _m)
|
||||
and not L.visible(L.field("p1_phase_import_entities"), _m))
|
||||
check("one inverter shows the one-inverter stops, not the per-inverter ones",
|
||||
L.visible(L.field("sign_disagree_w"), _m)
|
||||
and not L.visible(L.field("unit_sign_disagree_w"), _m))
|
||||
check("maintenance details hide while maintenance is off",
|
||||
not L.visible(L.field("maintenance_charge_w"), _m))
|
||||
|
||||
print("\n5. UI code names option keys in two files only")
|
||||
_KEY = re.compile(r"""["']([a-z0-9]+(?:_[a-z0-9]+)+)["']""")
|
||||
_src = io.open(os.path.join(HERE, "app", "uimodel.py"), encoding="utf-8").read()
|
||||
_owned = set(L.DERIVED) | {"inverters", "price_provider",
|
||||
"price_taxes_eur_kwh", "arbitrage_enabled"}
|
||||
_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",
|
||||
"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, web.py and uipage.py name no option key ({_leak})",
|
||||
not _leak)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,224 @@
|
||||
"""UX-13 P0: stored options <-> page model, exactly. `python3 test_uimodel.py`
|
||||
|
||||
app/uimodel.py derives the keys the page does not show (the flat inverter,
|
||||
the solar keys, static_profile, the levies figure, arbitrage_enabled). Part 1
|
||||
round-trips every options blob the test suite hands the validator
|
||||
(scenarios/ux13/, recorded by tools/ux13_validator_calls.py at 652097a), the
|
||||
TEST-23 option files and config.yaml's defaults: options -> model -> JSON ->
|
||||
options must be identical. Parts 2-6 edit one page field and check the blob.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
logging.disable(logging.WARNING) # the validator's own log lines
|
||||
|
||||
from app import uimodel as U # noqa: E402
|
||||
from app.price import (FLEMISH_TAXES_COMPONENTS_EUR_KWH, # noqa: E402
|
||||
PRICE_OPTION_DEFAULTS)
|
||||
from app.static_tariff import parse_static_profile # noqa: E402
|
||||
from app.validate import validate_startup_config # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def trip(options):
|
||||
return U.ui_to_options(json.loads(json.dumps(U.options_to_ui(options))))
|
||||
|
||||
|
||||
def edit(options, **fields):
|
||||
model = json.loads(json.dumps(U.options_to_ui(options)))
|
||||
for k, v in fields.items():
|
||||
model[k.replace("__", ".")] = v
|
||||
return U.ui_to_options(model)
|
||||
|
||||
|
||||
def raises(fn):
|
||||
try:
|
||||
fn()
|
||||
except U.DeriveError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def defaults():
|
||||
"""config.yaml's `options:` block, read without PyYAML (not in CI)."""
|
||||
text = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
|
||||
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
|
||||
|
||||
|
||||
DEFAULTS = defaults()
|
||||
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
|
||||
soc_entity="sensor.soc", batt_entity="sensor.batt")
|
||||
|
||||
print("1. every known blob survives the round trip unchanged")
|
||||
_calls = [json.loads(line) for line in gzip.open(os.path.join(
|
||||
HERE, "scenarios", "ux13", "validator-calls-652097a.jsonl.gz"), "rt")]
|
||||
_test23 = sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
||||
"*.json")))
|
||||
_blobs = ([("config.yaml", DEFAULTS)]
|
||||
+ [(c["source"], c["opts"]) for c in _calls
|
||||
if isinstance(c["opts"], dict)]
|
||||
+ [(os.path.basename(f), json.load(open(f))) for f in _test23])
|
||||
_bad = []
|
||||
for _src, _o in _blobs:
|
||||
try:
|
||||
_back = trip(_o)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_bad.append(f"{_src}: {type(e).__name__} {e}")
|
||||
continue
|
||||
if json.dumps(_back, sort_keys=True) != json.dumps(_o, sort_keys=True):
|
||||
_bad.append(f"{_src}: {sorted(set(_back) ^ set(_o)) or 'values'}")
|
||||
print(f" {len(_calls)} validator calls, {len(_test23)} TEST-23 files, "
|
||||
f"config.yaml defaults")
|
||||
check(f"all {len(_blobs)} are identical after options -> model -> JSON -> "
|
||||
f"options (not: {_bad[:3]})", not _bad and len(_calls) >= 200
|
||||
and len(_test23) >= 5 and len(DEFAULTS) >= 78)
|
||||
check("config.yaml was read whole: the defaults hold every option",
|
||||
"inverters" in DEFAULTS and DEFAULTS["inverters"] == []
|
||||
and DEFAULTS["price_taxes_eur_kwh"] == 0.11736)
|
||||
_m = U.options_to_ui(DEFAULTS)
|
||||
check("the page model never shows a derived key: it keeps them aside",
|
||||
not {"setpoint_entity", "pv_entity", "pv_entities", "static_profile",
|
||||
"price_taxes_profile"} & set(_m)
|
||||
and set(_m["keep"]) >= {"static_profile", "pv_entity", "max_w"})
|
||||
check("a blob that lacks keys gains none", trip({"max_w": 900}) == {"max_w": 900})
|
||||
|
||||
print("\n2. one inverter or a list")
|
||||
_one = edit(SITE, inverters=[dict(U.options_to_ui(SITE)["inverters"][0],
|
||||
max_w=1500)])
|
||||
check("editing the one inverter writes the flat keys and keeps the list empty",
|
||||
_one["max_w"] == 1500 and _one["inverters"] == []
|
||||
and _one["setpoint_entity"] == "number.goodwe_setpoint")
|
||||
_card = U.options_to_ui(SITE)["inverters"][0]
|
||||
_two = edit(SITE, inverters=[
|
||||
dict(_card, name="inverter-a", capacity_kwh=None),
|
||||
dict(_card, name="inverter-b", setpoint_entity="number.goodwe_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b")])
|
||||
check("a second inverter writes the list and clears the flat power request "
|
||||
"entity, which the validator refuses beside a list",
|
||||
len(_two["inverters"]) == 2 and _two["setpoint_entity"] == ""
|
||||
and "capacity_kwh" not in _two["inverters"][0]
|
||||
and not validate_startup_config(_two))
|
||||
_listed = dict(SITE, setpoint_entity="", inverters=[
|
||||
{"name": "garage", "setpoint_entity": "number.g", "max_w": 2000,
|
||||
"bms_soc_floor": 10}])
|
||||
_l1 = edit(_listed, inverters=[dict(_listed["inverters"][0], max_w=1800)])
|
||||
check("a list of one stays a list, so the inverter keeps its name and stored "
|
||||
"state", _l1["inverters"] == [dict(_listed["inverters"][0], max_w=1800)])
|
||||
check("editing a listed inverter keeps the unread flat keys as stored",
|
||||
all(_l1[k] == _listed[k] for k in ("setpoint_entity", "soc_entity",
|
||||
"batt_entity", "max_w")))
|
||||
check("a name on the flat inverter is refused rather than dropped",
|
||||
raises(lambda: edit(SITE, inverters=[dict(_card, name="garage")])))
|
||||
_raw = dict(SITE, inverters="garage")
|
||||
check("a roster no card can show is kept when another field is saved, and "
|
||||
"cannot be edited here",
|
||||
edit(_raw, gain=0.5)["inverters"] == "garage"
|
||||
and raises(lambda: edit(_raw, inverters=[_card])))
|
||||
|
||||
print("\n3. solar rows")
|
||||
check("a stored flip reads as a reversed row",
|
||||
U.options_to_ui(dict(DEFAULTS, pv_entity="sensor.pv",
|
||||
pv_invert_entities=["sensor.pv"]))["ui.solar"]
|
||||
== [{"entity": "sensor.pv", "reversed": True}])
|
||||
check("one row writes pv_entity",
|
||||
edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}])
|
||||
["pv_entity"] == "sensor.pv")
|
||||
_pv2 = edit(dict(DEFAULTS, pv_entity="sensor.a"), ui__solar=[
|
||||
{"entity": "sensor.a", "reversed": False},
|
||||
{"entity": "sensor.b", "reversed": True}])
|
||||
check("two rows write pv_entities, clear pv_entity and flip the reversed one",
|
||||
_pv2["pv_entities"] == ["sensor.a", "sensor.b"] and _pv2["pv_entity"] == ""
|
||||
and _pv2["pv_invert_entities"] == ["sensor.b"])
|
||||
_stray = dict(DEFAULTS, pv_entities=["sensor.a", "sensor.b"],
|
||||
pv_invert_entities=["sensor.b", "sensor.old"])
|
||||
_pv1 = edit(_stray, ui__solar=[{"entity": "sensor.a", "reversed": False}])
|
||||
check("removing a reversed row drops its flip, and a flip that was never a row "
|
||||
"stays as stored", _pv1["pv_entities"] == ["sensor.a"]
|
||||
and _pv1["pv_invert_entities"] == ["sensor.old"])
|
||||
|
||||
print("\n4. contract and prices")
|
||||
_fixed = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31)
|
||||
check("a fixed price writes a profile the tariff parser reads as one price",
|
||||
_fixed["price_provider"] == "off"
|
||||
and [p.eur_per_kwh for p in parse_static_profile(
|
||||
_fixed["static_profile"]).periods] == [0.31]
|
||||
and U.options_to_ui(_fixed)["ui.contract"] == "fixed")
|
||||
_dn = edit(SITE, ui__contract="daynight", ui__day_price=0.34,
|
||||
ui__night_price=0.26)
|
||||
check("day and night write a profile that starts and reads back the same",
|
||||
not validate_startup_config(_dn)
|
||||
and U.options_to_ui(_dn)["ui.day_price"] == 0.34
|
||||
and U.options_to_ui(_dn)["ui.night_price"] == 0.26)
|
||||
_dyn = edit(_dn, ui__contract="dynamic", price_provider="generic_dayahead")
|
||||
check("a dynamic contract keeps the day and night profile as its fallback",
|
||||
_dyn["price_provider"] == "generic_dayahead"
|
||||
and _dyn["static_profile"] == _dn["static_profile"])
|
||||
check("a dynamic contract without a price source is refused here",
|
||||
raises(lambda: edit(DEFAULTS, ui__contract="dynamic")))
|
||||
_odd = json.dumps({"periods": [
|
||||
{"from": "00:00", "to": "12:00", "days": "mon-sun", "eur_per_kwh": 0.2},
|
||||
{"from": "12:00", "to": "24:00", "days": "mon-sun", "eur_per_kwh": 0.3}]})
|
||||
_om = U.options_to_ui(dict(DEFAULTS, static_profile=_odd))
|
||||
check("odd windows show as 'custom' and survive saving another field",
|
||||
_om["ui.contract"] == "custom"
|
||||
and edit(dict(DEFAULTS, static_profile=_odd), gain=0.5)
|
||||
["static_profile"] == _odd)
|
||||
|
||||
print("\n5. region presets")
|
||||
_fl = U.REGION_PRESETS["flanders"]
|
||||
check("the Flanders preset is the sum of app/price.py's components, and is the "
|
||||
"shipped default", _fl["taxes_eur_kwh"] == round(sum(
|
||||
v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5)
|
||||
== PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == DEFAULTS[
|
||||
"price_taxes_eur_kwh"])
|
||||
check("every preset carries its year", all(
|
||||
isinstance(p["year"], int) and p["year"] >= 2026
|
||||
for p in U.REGION_PRESETS.values()))
|
||||
_own = dict(DEFAULTS, price_taxes_eur_kwh=0.06)
|
||||
check("a figure that is no preset reads as 'own', and choosing Flanders "
|
||||
"writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN
|
||||
and edit(_own, ui__region="flanders")["price_taxes_eur_kwh"] == 0.11736)
|
||||
_prof = dict(DEFAULTS, price_taxes_eur_kwh=0.12,
|
||||
price_taxes_profile='{"day": 0.14, "night": 0.1}')
|
||||
check("'own' keeps a stored levies table when the figure is edited",
|
||||
edit(_prof, price_taxes_eur_kwh=0.13)["price_taxes_profile"]
|
||||
== _prof["price_taxes_profile"])
|
||||
check("'own' stores the typed figure",
|
||||
edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13)
|
||||
["price_taxes_eur_kwh"] == 0.13)
|
||||
|
||||
print("\n6. arbitrage_enabled: the OPT-11 hook changes nothing yet")
|
||||
check("the hook answers None while battery modes do not exist",
|
||||
U.arbitrage_from_batteries(U.options_to_ui(DEFAULTS)) is None)
|
||||
check("so the toggle is stored as the page has it",
|
||||
edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1808,6 +1808,47 @@ check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
|
||||
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
|
||||
and shadow.mode({"shadow_mode": "garbage"}) == "on")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\nUX-13 - the warning tier: notes for the settings page, never refusals")
|
||||
from app.validate import Note, WATT_ENTITY_KEYS, config_notes # noqa: E402
|
||||
|
||||
_warned = base(integrator_max_w=3000, maintenance_discharge_w=2500,
|
||||
price_provider="generic_dayahead", static_profile="")
|
||||
_notes = []
|
||||
_with = validate_startup_config(_warned, -5000, 5000, notes=_notes)
|
||||
check("a config with three logged warnings starts, and gives three notes, "
|
||||
"each filed under the setting it names",
|
||||
_with == [] and validate_startup_config(_warned, -5000, 5000) == []
|
||||
and sorted(n.keys for n in _notes) == [
|
||||
("integrator_max_w",), ("maintenance_discharge_w",),
|
||||
("static_profile",)]
|
||||
and all(n.tier == "warn" and f"({n.keys[0]})" in n.text for n in _notes))
|
||||
_refused = base(setpoint_entity="", integrator_max_w=3000)
|
||||
_n2 = []
|
||||
check("collecting notes changes no refusal",
|
||||
validate_startup_config(_refused, notes=_n2)
|
||||
== validate_startup_config(_refused) != [] and len(_n2) == 1)
|
||||
_rows = {"number.goodwe_setpoint": {"unit": None},
|
||||
"sensor.soc": {"unit": "%"}, "sensor.batt": {"unit": "kW"}}
|
||||
_cn = config_notes(base(), entities=_rows)
|
||||
check("a battery power sensor in kW is warned about: the add-on takes it as "
|
||||
"watts", [n.keys for n in _cn] == [("batt_entity",)]
|
||||
and "kW" in _cn[0].text)
|
||||
_cn = config_notes(base(meter_entity="sensor.grid"), entities={})
|
||||
check("an entity Home Assistant does not have is named, and only the ones "
|
||||
"this meter source and roster shape read",
|
||||
sorted(n.keys[0] for n in _cn) == ["batt_entity", "meter_entity",
|
||||
"setpoint_entity", "soc_entity"])
|
||||
check("with no entity list (Home Assistant down) those checks are skipped",
|
||||
config_notes(base(), entities=None) == [])
|
||||
check("a peak limit below this month's peak is warned about",
|
||||
[n.keys for n in config_notes(base(peak_cap_w=2500),
|
||||
month_peak_w=2900)] == [("peak_cap_w",)]
|
||||
and config_notes(base(peak_cap_w=3500), month_peak_w=2900) == [])
|
||||
check("pv.py converts solar units itself, so no solar key is read as watts",
|
||||
not [k for k in WATT_ENTITY_KEYS if k.startswith("pv_")]
|
||||
and isinstance(_cn[0], Note))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -1422,14 +1422,28 @@ for _t in range(0, 400, 5):
|
||||
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
|
||||
print(f" site: {_wd_site.reason!r}")
|
||||
print(f" unit: {_wd_unit.reason!r}")
|
||||
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
|
||||
"SHIPPED. It is what an operator reads on the status page, what goes in "
|
||||
"the action log and what is persisted to /data/state.json, so it is "
|
||||
"asserted against the literal rather than against a format string",
|
||||
_wd_site.reason == (
|
||||
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
|
||||
"180 s)"))
|
||||
# UX-11: this was a byte pin. The reason is now the evidence: the action
|
||||
# log, the DEBUG line and /data/state.json carry it, and the ERROR line quotes
|
||||
# only its code (`messages.reason_code()`). So what is pinned is what it says:
|
||||
# the code in front, where every saved reason has had it and where the ERROR
|
||||
# line finds it, then the command and the reading in that order (habit 14),
|
||||
# the time held and both limits.
|
||||
_sr = _wd_site.reason
|
||||
_order = [_sr.find(t) for t in ("commanded -1000 W", "meter reading +1500 W",
|
||||
"for 180 s", "both over 300 W",
|
||||
"held over 180 s")]
|
||||
check("⚠️ THE SITE INSTANCE'S REASON SAYS WHAT SHIPPED, IN THE SAME ORDER: "
|
||||
"the code first, then what was commanded, what the meter read, how "
|
||||
f"long, and the two limits ({_order})",
|
||||
_sr.startswith("SIGN_DISAGREEMENT: ")
|
||||
and messages.reason_code(_sr) == "SIGN_DISAGREEMENT"
|
||||
and all(i > 0 for i in _order) and _order == sorted(_order))
|
||||
# UX-11 review: the text itself did not change, and it is what state.json
|
||||
# holds, so the byte pin stays beside the meaning pin.
|
||||
check("...and it is still byte-identical to the one that shipped",
|
||||
_sr == ("SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held "
|
||||
"over 180 s)"))
|
||||
check("...and the per-unit instance names what it ACTUALLY compared instead. "
|
||||
"'against a meter reading' there would be a false statement about which "
|
||||
"instrument produced the evidence, on the one line somebody woken by "
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-52's evidence: the 16-day capture replayed with and without the
|
||||
battery-refuses hold. `python3 tools/replay_refusal.py`
|
||||
|
||||
Same disturbance, fitted plant and pack floor as `replay_recorded_loop.py`,
|
||||
plus main.py's reseed, 15 s input hold and 0 W rules and the REAL SAFETY-37/57
|
||||
gate on a register published every 10 s. `old` is `compute()` with
|
||||
`refused=None`, which is release/1.0's law unchanged; `new` carries the refusal,
|
||||
runs the probe on main.py's clock and seeds a refusal from a gate trip.
|
||||
Modelled watt-hours: read the deltas, not the absolutes (see that tool).
|
||||
"""
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
ADDON = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ADDON))
|
||||
sys.path.insert(0, str(ADDON / "tools"))
|
||||
import replay_recorded_loop as R # noqa: E402
|
||||
from app import freshness, control as NEW # noqa: E402
|
||||
|
||||
def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
|
||||
law = NEW
|
||||
tun = law.Tuning(**R.LIVE)
|
||||
t0, g0, b0, sp0, soc0 = rows[0]
|
||||
pl = R.Plant(*fit[:4], soc0); pl.seed(t0, b0, sp0)
|
||||
st = dict(target=sp0, i_w=sp0, sat=0, loop_w=sp0, ref=0, anchor=None)
|
||||
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg, reg_t = None, -1e18
|
||||
bad, trips, was = None, 0, False
|
||||
M = dict(und=0.0, imp=0.0, exp=0.0, q={}, run=0.0, mx=0.0, last=None, refh=0.0, probes=0)
|
||||
for i in range(1, len(rows)):
|
||||
t, gr, ba, sp, soc = rows[i]
|
||||
dt = t - rows[i - 1][0]
|
||||
if dt > R.TICK_CAP_S:
|
||||
pl = R.Plant(*fit[:4], soc); pl.seed(t, ba, sp)
|
||||
st.update(target=sp, i_w=sp, sat=0, loop_w=sp, ref=0, anchor=None)
|
||||
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg_t = -1e18; bad = None
|
||||
continue
|
||||
b = pl.step(t, dt, st["target"]); grid = gr + ba - b
|
||||
if t - reg_t >= publish_s:
|
||||
reg, reg_t = float(round(b)), t
|
||||
fr.submit(reg, st["target"], t); batt = fr.reading()
|
||||
if fr.stale and not was:
|
||||
trips += 1
|
||||
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
|
||||
and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
|
||||
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
|
||||
was = fr.stale
|
||||
if batt is None:
|
||||
bad = t if bad is None else bad
|
||||
if t - bad >= stale_input_s:
|
||||
st.update(target=0.0, i_w=0.0, loop_w=None)
|
||||
else:
|
||||
bad = None
|
||||
if st["loop_w"] is None or st["target"] != st["loop_w"]:
|
||||
st["i_w"] = st["target"]
|
||||
if which == "old":
|
||||
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"],
|
||||
refused=None)
|
||||
else:
|
||||
probe = False
|
||||
if st["ref"] and st["anchor"] is not None:
|
||||
h = t - st["anchor"]
|
||||
if h >= NEW.PROBE_EVERY_S + NEW.PROBE_S: st["anchor"] = t
|
||||
else: probe = h >= NEW.PROBE_EVERY_S
|
||||
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"], refused=st["ref"], probe=probe)
|
||||
if d.refused and not st["ref"]: st["anchor"] = t
|
||||
if not d.refused: st["anchor"] = None
|
||||
if probe and d.target_w == NEW.PROBE_W and M["last"] != NEW.PROBE_W: M["probes"] += 1
|
||||
st["ref"] = d.refused
|
||||
st.update(sat=d.sat_count, i_w=d.i_w, loop_w=d.target_w, target=d.target_w)
|
||||
tg = st["target"]
|
||||
if tg - b > 1000: M["und"] += dt / 3600
|
||||
if st["ref"]: M["refh"] += dt / 3600
|
||||
M["imp"] += max(0, grid) * dt / 3.6e6; M["exp"] += max(0, -grid) * dt / 3.6e6
|
||||
k = math.floor(t / 900); a = M["q"].setdefault(k, [0.0, 0.0]); a[0] += grid * dt; a[1] += dt
|
||||
M["run"] = M["run"] + dt if (M["last"] == tg and abs(tg) >= 250) else 0.0
|
||||
M["mx"] = max(M["mx"], M["run"]); M["last"] = tg
|
||||
worst = max(max(0.0, s / d) for s, d in M["q"].values() if d > 600)
|
||||
return trips, M, worst
|
||||
|
||||
def main():
|
||||
rows = R.stream()
|
||||
fit = R.fit_plant(rows, stride=7)
|
||||
print(f"fit {[round(x, 2) for x in fit]}, {len(rows)} meter instants")
|
||||
for label, f in (("fitted plant", fit),
|
||||
("plant offset 0 W", (fit[0], fit[1], fit[2], 0.0, fit[4]))):
|
||||
for which in ("old", "new"):
|
||||
tr, M, w = go(which, rows, f)
|
||||
print(f"{label:17s} {which}: gate trips {tr:4d} | request >1 kW above "
|
||||
f"delivery {M['und']:6.1f} h | longest held >=250 W "
|
||||
f"{M['mx'] / 3600:5.2f} h | import {M['imp']:6.1f} kWh export "
|
||||
f"{M['exp']:5.1f} kWh | worst 15-min {w:5.0f} W | refused "
|
||||
f"{M['refh']:5.1f} h, probes {M['probes']}", flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,287 @@
|
||||
"""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.
|
||||
|
||||
P4: STATE `new` is a new install (config.yaml's defaults with one power
|
||||
request entity, an empty Store made at this boot, control never started), so the page opens
|
||||
the setup wizard by itself. A one-entity read of sensor.grid steps through
|
||||
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
|
||||
reversed meter (down).
|
||||
"""
|
||||
|
||||
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):
|
||||
fresh = False # app/store.py: no state file at this boot
|
||||
|
||||
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):
|
||||
s = next((dict(x) for x in STATES if x["entity_id"] == e), None)
|
||||
if s and e == "sensor.grid":
|
||||
n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1
|
||||
s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)])
|
||||
return s
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return STATES
|
||||
|
||||
|
||||
KETTLE_READS = (-10, 1990, 1990, -10)
|
||||
KETTLE_COUNT = [0]
|
||||
|
||||
|
||||
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"):
|
||||
if state == "new":
|
||||
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
|
||||
store = Store()
|
||||
store.fresh = True # a new /data: the wizard opens by itself
|
||||
c = Controller(dict(stored), Hass(), store, Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = False
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
await runner.setup()
|
||||
await aioweb.TCPSite(runner, host, port).start()
|
||||
print(f"serving new on http://127.0.0.1:{port}/", flush=True)
|
||||
await asyncio.Event().wait()
|
||||
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]))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""UX-13: record every `validate_startup_config()` call the test suite makes,
|
||||
and compare two validators over those calls.
|
||||
|
||||
python3 tools/ux13_validator_calls.py record OUT.jsonl.gz
|
||||
python3 tools/ux13_validator_calls.py compare OLD_APP_PARENT OUT.jsonl.gz
|
||||
|
||||
`record` runs each test_*.py with the validator wrapped (in this directory, so
|
||||
run it where the suite runs: the CI image). `compare` runs the recorded calls
|
||||
through the validator under OLD_APP_PARENT (a directory holding an `app/`,
|
||||
e.g. from `git archive <base> goodwe_controller/app`) and through this tree's,
|
||||
and prints every call whose refusals differ. P0 of UX-13 used it to show that
|
||||
adding the warning tier changed no refusal. scenarios/ux13/ holds the record
|
||||
test_uimodel.py round-trips.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
_WRAP = r"""
|
||||
import json, runpy, sys
|
||||
sys.path.insert(0, ".")
|
||||
from app import validate
|
||||
real = validate.validate_startup_config
|
||||
out = open(sys.argv[1], "a")
|
||||
def rec(opts, dev_min=None, dev_max=None, *a, **kw):
|
||||
try:
|
||||
out.write(json.dumps({"opts": opts, "dev_min": dev_min,
|
||||
"dev_max": dev_max}) + "\n")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return real(opts, dev_min, dev_max, *a, **kw)
|
||||
validate.validate_startup_config = rec
|
||||
sys.argv = sys.argv[2:]
|
||||
try:
|
||||
runpy.run_path(sys.argv[0], run_name="__main__")
|
||||
except SystemExit:
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def record(out_path):
|
||||
tmp = out_path + ".part"
|
||||
open(tmp, "w").close()
|
||||
rows, seen = [], set()
|
||||
for name in sorted(f for f in os.listdir(HERE)
|
||||
if f.startswith("test_") and f.endswith(".py")):
|
||||
before = sum(1 for _ in open(tmp))
|
||||
subprocess.run([sys.executable, "-B", "-c", _WRAP, tmp, name],
|
||||
cwd=HERE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
with open(tmp) as fh:
|
||||
for line in list(fh)[before:]:
|
||||
call = json.loads(line)
|
||||
key = json.dumps(call, sort_keys=True)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
rows.append(dict(call, source=name))
|
||||
os.remove(tmp)
|
||||
with gzip.open(out_path, "wt") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, sort_keys=True) + "\n")
|
||||
print(f"{len(rows)} distinct validator calls -> {out_path}")
|
||||
|
||||
|
||||
def _run(app_parent, path):
|
||||
code = r"""
|
||||
import gzip, json, logging, sys
|
||||
logging.disable(logging.CRITICAL)
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from app.validate import validate_startup_config as v
|
||||
out = []
|
||||
for line in gzip.open(sys.argv[2], "rt"):
|
||||
c = json.loads(line)
|
||||
try:
|
||||
out.append(v(c["opts"], c["dev_min"], c["dev_max"]))
|
||||
except Exception as e:
|
||||
out.append("raised " + type(e).__name__)
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
got = subprocess.run([sys.executable, "-B", "-c", code, app_parent, path],
|
||||
capture_output=True, text=True, check=True)
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def compare(old_parent, path):
|
||||
old, new = _run(old_parent, path), _run(HERE, path)
|
||||
differ = [i for i, (a, b) in enumerate(zip(old, new)) if a != b]
|
||||
print(f"{len(old)} calls, {sum(1 for r in old if r)} refused by the old "
|
||||
f"validator, {len(differ)} with different refusals")
|
||||
for i in differ:
|
||||
print(f" call {i}:\n old {old[i]}\n new {new[i]}")
|
||||
return 1 if differ else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "record":
|
||||
record(sys.argv[2])
|
||||
elif len(sys.argv) == 4 and sys.argv[1] == "compare":
|
||||
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's
|
||||
own validator run over them.
|
||||
|
||||
python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs
|
||||
docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/w/tools/ux13_wizard_blobs.py --supervisor /blobs
|
||||
|
||||
The wizard edits the page model one step at a time and saves it through
|
||||
uipage.to_options(), like the Installer view. `blobs()` walks each step's
|
||||
choices (meter source and sign, one to three inverters, solar rows, every
|
||||
contract and price source, region, peak limit, shadow mode) from a new
|
||||
install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator
|
||||
accepts, because the wizard saves nothing it refuses. test_setup.py checks
|
||||
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
|
||||
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- the wizard's steps, as edits to the page model ------------------------ #
|
||||
METERS = {
|
||||
"entity": {"meter_source": "off", "meter_entity": "sensor.grid"},
|
||||
"entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid",
|
||||
"meter_invert": True},
|
||||
"signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"},
|
||||
"signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 3, "p1_phase_net_entities": [
|
||||
"sensor.l1", "sensor.l2", "sensor.l3"]},
|
||||
"dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp",
|
||||
"p1_export_entity": "sensor.exp"},
|
||||
"homewizard": {"meter_source": "homewizard_local",
|
||||
"p1_host": "192.0.2.50"},
|
||||
"mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"},
|
||||
}
|
||||
|
||||
|
||||
def _card(i, flat=False, **more):
|
||||
card = {"setpoint_entity": f"number.inv{i}_power",
|
||||
"soc_entity": f"sensor.inv{i}_level",
|
||||
"batt_entity": f"sensor.inv{i}_battery", "batt_invert": False,
|
||||
"max_w": 2500, "bms_soc_floor": 10}
|
||||
if flat:
|
||||
card.update(name="", capacity_kwh=None)
|
||||
else:
|
||||
card.update(name=f"inverter-{i}", capacity_kwh=5.0)
|
||||
card.update(more)
|
||||
return card
|
||||
|
||||
|
||||
ROSTERS = {
|
||||
"one": ("flat", [_card(1, flat=True)]),
|
||||
"one_flipped": ("flat", [_card(1, flat=True, batt_invert=True,
|
||||
max_w=3000)]),
|
||||
"two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]),
|
||||
"three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]),
|
||||
}
|
||||
SOLAR = {
|
||||
"none": [],
|
||||
"one": [{"entity": "sensor.pv", "reversed": False}],
|
||||
"one_reversed": [{"entity": "sensor.pv", "reversed": True}],
|
||||
"two": [{"entity": "sensor.pv_a", "reversed": False},
|
||||
{"entity": "sensor.pv_b", "reversed": True}],
|
||||
}
|
||||
PRICES = {
|
||||
"none": {"ui.contract": "none", "ui.fallback": "none"},
|
||||
"fixed": {"ui.contract": "fixed", "ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.31},
|
||||
"daynight": {"ui.contract": "daynight", "ui.fallback": "daynight",
|
||||
"ui.day_price": 0.34, "ui.night_price": 0.25},
|
||||
"dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead",
|
||||
"ui.fallback": "none"},
|
||||
"dynamic_fixed": {"ui.contract": "dynamic",
|
||||
"price_provider": "generic_dayahead",
|
||||
"ui.fallback": "fixed", "ui.fixed_price": 0.3},
|
||||
"dynamic_daynight": {"ui.contract": "dynamic",
|
||||
"price_provider": "generic_dayahead",
|
||||
"ui.fallback": "daynight", "ui.day_price": 0.3,
|
||||
"ui.night_price": 0.2},
|
||||
"formula": {"ui.contract": "dynamic", "price_provider": "custom_formula",
|
||||
"price_formula": "<the documented example>",
|
||||
"ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.3},
|
||||
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
|
||||
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.3},
|
||||
}
|
||||
REGIONS = {"flanders": {"ui.region": "flanders"},
|
||||
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
|
||||
PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""},
|
||||
"forecast": {"peak_cap_w": 4200,
|
||||
"peak_forecast_entity": "sensor.peak_forecast"},
|
||||
"high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}}
|
||||
SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW)
|
||||
BASE = {"meter": "entity", "roster": "one", "solar": "none",
|
||||
"prices": "none", "region": "own", "peak": "low", "shadow": "on"}
|
||||
|
||||
|
||||
def plans():
|
||||
"""Each step's choices once, with the other steps at BASE, then a few
|
||||
whole sites that combine them."""
|
||||
axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR,
|
||||
"prices": PRICES, "region": REGIONS, "peak": PEAKS,
|
||||
"shadow": dict.fromkeys(SHADOWS)}
|
||||
seen = []
|
||||
for axis, choices in axes.items():
|
||||
for choice in choices:
|
||||
plan = dict(BASE, **{axis: choice})
|
||||
if plan not in seen:
|
||||
seen.append(plan)
|
||||
for plan in (
|
||||
{"meter": "dsmr", "roster": "two", "solar": "two",
|
||||
"prices": "dynamic_daynight", "region": "flanders",
|
||||
"peak": "forecast", "shadow": "on"},
|
||||
{"meter": "signed_3", "roster": "three", "solar": "one_reversed",
|
||||
"prices": "daynight", "region": "flanders", "peak": "high",
|
||||
"shadow": "auto"},
|
||||
{"meter": "homewizard", "roster": "one_flipped", "solar": "one",
|
||||
"prices": "fixed", "region": "own", "peak": "forecast",
|
||||
"shadow": "on"},
|
||||
{"meter": "mqtt", "roster": "two", "solar": "none",
|
||||
"prices": "formula", "region": "flanders", "peak": "low",
|
||||
"shadow": "on"}):
|
||||
seen.append(plan)
|
||||
return seen
|
||||
|
||||
|
||||
def apply(model, plan):
|
||||
"""The page model after the wizard's six steps, as the page leaves it."""
|
||||
from app import uilayout as L, uimodel as U
|
||||
m = copy.deepcopy(model)
|
||||
m.update(copy.deepcopy(METERS[plan["meter"]]))
|
||||
shape, cards = ROSTERS[plan["roster"]]
|
||||
cards = copy.deepcopy(cards)
|
||||
if m.get("inverter_shape") == "list":
|
||||
# A site stored as a list stays one: one card keeps a name.
|
||||
for i, card in enumerate(cards, 1):
|
||||
card.update(name=card["name"] or f"inverter-{i}",
|
||||
capacity_kwh=card["capacity_kwh"] or 5.0)
|
||||
m["inverters"] = cards
|
||||
m[U.COUNT] = len(cards)
|
||||
if shape == "list":
|
||||
m.update(U.GROWN)
|
||||
m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]])
|
||||
m.update(copy.deepcopy(PRICES[plan["prices"]]))
|
||||
if plan["prices"] == "formula":
|
||||
from app.custom_formula import _LEGS
|
||||
m["price_formula"] = _LEGS["price_formula"].example
|
||||
m.update(copy.deepcopy(REGIONS[plan["region"]]))
|
||||
if plan["region"] != "own":
|
||||
m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][
|
||||
"taxes_eur_kwh"]
|
||||
m.update(PEAKS[plan["peak"]])
|
||||
field, on, off = L.SHADOW
|
||||
m[field] = on if plan["shadow"] == "on" else (
|
||||
m.get(field) if m.get(field) != on else off)
|
||||
return m
|
||||
|
||||
|
||||
def starts():
|
||||
"""(name, stored options) the wizard opens on: a new install as shipped
|
||||
(config.yaml's defaults, served in setup-only mode since UX-15), one with a
|
||||
power request entity typed in the Configuration tab, and the TEST-23
|
||||
sites."""
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import ux13_demo as D
|
||||
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
|
||||
out = [("empty", D.defaults(D.CONFIG)), ("new", new)]
|
||||
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
||||
"*.json")))[:3]:
|
||||
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
|
||||
**json.load(open(f)))))
|
||||
return out
|
||||
|
||||
|
||||
def blobs():
|
||||
"""[(name, blob, refusals)] for every plan from every start."""
|
||||
sys.path.insert(0, HERE)
|
||||
from app import uimodel as U, uipage
|
||||
from app.validate import validate_startup_config
|
||||
out = []
|
||||
for start, stored in starts():
|
||||
model = json.loads(json.dumps(U.options_to_ui(stored)))
|
||||
for plan in plans():
|
||||
name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items())
|
||||
posted = browser(json.loads(json.dumps(apply(model, plan))))
|
||||
blob = uipage.to_options(posted, stored)
|
||||
out.append((name, blob, validate_startup_config(blob)))
|
||||
return out
|
||||
|
||||
|
||||
# --- run inside the Supervisor image --------------------------------------- #
|
||||
def supervisor(folder):
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
import voluptuous as vol
|
||||
from pathlib import Path
|
||||
from supervisor.apps.options import AppOptions
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml"))
|
||||
check = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, cfg["schema"], cfg["name"], "local_goodwe_controller")))
|
||||
bad = 0
|
||||
files = sorted(glob.glob(os.path.join(folder, "*.json")))
|
||||
for f in files:
|
||||
blob = json.load(open(f))
|
||||
try:
|
||||
got = check(copy.deepcopy(blob))
|
||||
dropped = sorted(set(blob) - set(got))
|
||||
ok = not dropped
|
||||
why = f"dropped {dropped}" if dropped else f"{len(got)} keys"
|
||||
except vol.Invalid as err:
|
||||
ok, why = False, str(err)
|
||||
bad += not ok
|
||||
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}")
|
||||
print(f"{len(files) - bad} of {len(files)} accepted by AppOptions "
|
||||
f"({cfg['version']})")
|
||||
return 1 if bad or not files else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["--supervisor"]:
|
||||
sys.exit(supervisor(sys.argv[2]))
|
||||
if sys.argv[1:2] == ["--dump"]:
|
||||
os.makedirs(sys.argv[2], exist_ok=True)
|
||||
n = 0
|
||||
for name, blob, refused in blobs():
|
||||
if refused:
|
||||
print("refused, not written:", name, refused[0])
|
||||
continue
|
||||
with open(os.path.join(sys.argv[2], name + ".json"), "w") as f:
|
||||
json.dump(blob, f, indent=1)
|
||||
n += 1
|
||||
print(f"{n} blobs written to {sys.argv[2]}")
|
||||
sys.exit(0)
|
||||
print(__doc__)
|
||||
Reference in New Issue
Block a user