Compare commits
7
Commits
09b84e30b0
..
OPT-09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab267c4f3 | ||
|
|
8d83ef0dfb | ||
|
|
f8e501393f | ||
|
|
3d2999337d | ||
|
|
bcee2356e0 | ||
|
|
2bb07ccdde | ||
|
|
fdaf3fb54f |
@@ -14,13 +14,20 @@ goodwe_controller/ the add-on
|
||||
hass.py Supervisor/Core API access
|
||||
store.py persistent state in /data
|
||||
mqtt.py optional status entities
|
||||
web.py ingress UI
|
||||
web.py ingress UI — routes only, status and configuration
|
||||
config_page.py the configuration page's markup, style and script
|
||||
configui.py its field model, built from the Supervisor's own
|
||||
schema + translations — owns no key, label or rule
|
||||
selfconfig.py reading and writing this add-on's own options,
|
||||
with the validate-first / compare-and-swap gate
|
||||
main.py orchestration, heartbeat, failsafe behaviour
|
||||
test_control.py runnable checks — no framework needed
|
||||
test_maintenance.py runnable checks — walks a full cycle in fake time
|
||||
DOCS.md the add-on's documentation tab
|
||||
MUTATION.md how to run a mutation so the result means something
|
||||
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
|
||||
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.8.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
|
||||
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
|
||||
what happens when nobody does.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
|
||||
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
|
||||
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
|
||||
Home Assistant's Configuration tab. Both edit the same options blob; neither is
|
||||
authoritative, the stored blob is. What the new one can do that the old one
|
||||
structurally cannot is show `validate.py`'s CROSS-FIELD refusals - two inverters
|
||||
naming one `setpoint_entity`, a `maintenance_charge_w` above `max_w`, a flat
|
||||
`setpoint_entity` left set beside a roster - **next to the field, before saving**,
|
||||
instead of as exit 78 in a log afterwards. The Supervisor's tab knows only types
|
||||
and ranges and has no way to express a rule spanning two keys.
|
||||
|
||||
Four properties are the ones that could hurt somebody, and each is checked
|
||||
rather than asserted (`test_selfconfig.py`):
|
||||
|
||||
* a blob the shipped validator refuses is **never written** - because an invalid
|
||||
blob plus a restart is exit 78, and the panel goes down with the add-on;
|
||||
* a blob that **changed underneath** the page is refused, not clobbered, and the
|
||||
keys that moved are named. Two surfaces, one blob;
|
||||
* **every message comes from the shipped validator.** Proven by inserting one
|
||||
new rule into a COPY of `validate.py` and watching it arrive on the page with
|
||||
every UI file byte-identical;
|
||||
* **nothing on the surface commands an inverter or clears a latch.** Saving does
|
||||
not even restart: applying options costs a control interruption - the
|
||||
wind-down commands 0 W on every inverter - so that is a separate button that
|
||||
says so first.
|
||||
|
||||
⚠️ **NO SCHEMA CHANGE AND NO CONTROL CHANGE.** `config.yaml` is not modified at
|
||||
all by this card: the permissions the panel needs - `ingress: true`,
|
||||
`ingress_port: 8099`, `hassio_api: true`, `homeassistant_api: true` - were
|
||||
already declared, and the panel was already live.
|
||||
`tools/supervisor_selfapi_probe.py` re-measures them against Supervisor
|
||||
2026.08.0's own code, and found things the card had wrong: Supervisor's own
|
||||
`/addons/self/options/validate` is **not** reachable from inside an add-on, and
|
||||
the Configuration tab **can** be removed - at the cost of all 71 options, which
|
||||
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
@@ -156,6 +206,100 @@ the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||
|
||||
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
|
||||
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
|
||||
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
|
||||
that card's own reason: a missing cut-out would have to be *defaulted* and one
|
||||
default for two batteries protects the shallower pack with the deeper pack's
|
||||
number, whereas a missing capacity has an honest answer - no opinion about the
|
||||
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
|
||||
nothing across the roster** and rule 9 refuses a partial one. There is no flat
|
||||
`capacity_kwh`: at one inverter there is no split to weight.
|
||||
|
||||
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
|
||||
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
|
||||
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
|
||||
either pack could do. Two things now feed it, and they are not the same kind of
|
||||
thing:
|
||||
|
||||
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
|
||||
closes a unit's `hi` at 0 W when its own SoC is at or under its own
|
||||
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
|
||||
absolutely, so an empty pack is not asked to discharge and its share moves to
|
||||
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
|
||||
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
|
||||
Option A (no loop-side SoC floor) stands untouched on the shipped install.
|
||||
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
|
||||
for the fraction matching its **energy** headroom in kWh - not its power
|
||||
rating. The two packs are asymmetric in opposite directions (5000 W /
|
||||
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
|
||||
dividing hand the work to different packs.
|
||||
|
||||
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
|
||||
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
|
||||
site; it says nothing about whether the site can regulate. Measured (two 2000 W
|
||||
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
|
||||
weighted arm and the shipped arm command the **identical** +2000 W into the empty
|
||||
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
|
||||
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
|
||||
Closing the envelope does not recover those watts either - it reports them:
|
||||
`unmet_w` becomes +2000 W and the action log says so, where before the record
|
||||
read impeccable. A unit that cannot deliver still parks the site short by its
|
||||
share, and that is now visible instead of silent.
|
||||
|
||||
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
|
||||
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
|
||||
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
|
||||
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
|
||||
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
|
||||
exactly the states where the sum could be smaller - and moving that gate is
|
||||
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
|
||||
why.
|
||||
|
||||
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
|
||||
card's evidence was that a wedged unit and a pack at its floor are byte-identical
|
||||
in the power domain. After this card the cold pack is no longer *asked*, so the
|
||||
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
|
||||
(saturation persistence and |grid error|) are still inseparable on both arms, so
|
||||
the conclusion is unchanged and is now stated on the predicate's own inputs.
|
||||
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
|
||||
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
|
||||
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
|
||||
|
||||
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
|
||||
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
|
||||
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
|
||||
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
|
||||
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
|
||||
WEIGHT half was **false**: a weight is a function of a pack's ENERGY HEADROOM,
|
||||
not of what the pack delivers, so a wedged unit's weight moves whenever its
|
||||
headroom differs from its sibling's. And `delivery.delinquent()` names a unit
|
||||
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
|
||||
so shrinking the share shrinks the gap below the threshold and the alarm stops
|
||||
firing. Measured end to end with the real loop, the real allocator and the real
|
||||
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
|
||||
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
|
||||
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
|
||||
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
|
||||
125 W is under the threshold. The suppression window widens from a site target
|
||||
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
|
||||
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
|
||||
own rating, the allocator's last-resort pass hands the wedged unit a big share
|
||||
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
|
||||
meter is satisfied and no action-log line fires - **a wedged inverter with less
|
||||
energy headroom than its sibling can now go unreported at ordinary house
|
||||
loads.**
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
|
||||
the day it changes a named check moves. Closing it means making `delinquent()`
|
||||
relative to the ask rather than absolute, which is SAFETY-42's code and a design
|
||||
change; that is filed as its own card and is deliberately not done here.
|
||||
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
|
||||
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
|
||||
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
|
||||
the pre-rebase base, `464d398`).
|
||||
|
||||
## 0. What was actually wrong
|
||||
|
||||
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
|
||||
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
|
||||
base commit:
|
||||
|
||||
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
|
||||
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
|
||||
So `weights` was None on every tick of every install, `plan()` turned that into
|
||||
1.0 per unit, and the site target was split **50/50 regardless of what either
|
||||
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
|
||||
pass found the sharper content, and it is the sharper content that this branch is
|
||||
mostly about.
|
||||
|
||||
## 1. Two instruments, and they are not interchangeable
|
||||
|
||||
`allocator.py` states the rule this card is built on, and it is mechanical rather
|
||||
than advisory:
|
||||
|
||||
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
> "please do not use this unit", and it is honoured while any other unit has
|
||||
> headroom - but it is NOT a constraint.
|
||||
|
||||
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
|
||||
the preferred ones saturate, so that a preference can never manufacture a
|
||||
standing error at the meter. A pack at its own cut-out that is only *weighted*
|
||||
to zero is therefore still commanded to discharge the moment its sibling fills
|
||||
up. So this branch ships both:
|
||||
|
||||
| | question | mechanism |
|
||||
|---|---|---|
|
||||
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
|
||||
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
|
||||
|
||||
## 2. ⚠️ What the weights CANNOT do
|
||||
|
||||
ARCH-01's convex-combination argument - the gain at the meter is
|
||||
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
|
||||
guarantees that a bad split cannot **destabilise** the site. It says nothing
|
||||
about whether the site can **regulate**. A unit at its energy limit has
|
||||
`g_i = 0`; the combination is still inside the interval and the load it was
|
||||
asked to carry simply does not move.
|
||||
|
||||
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
|
||||
its own floor and therefore answering 0 W to any discharge command. The plant is
|
||||
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
|
||||
rating) and the loop is the real `compute()`:
|
||||
|
||||
| load | arm | site target | shares | grid | unmet |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
|
||||
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
|
||||
|
||||
Three things are worth reading off that table, and the second and third are the
|
||||
uncomfortable ones:
|
||||
|
||||
1. At a load the healthy pack can cover, the weights alone deliver the whole
|
||||
improvement. The shipped arm reaches the same meter reading only by winding
|
||||
the site target to **twice the load** and standing a +1500 W command on a
|
||||
battery that answers nothing, permanently saturation-frozen. That is not a
|
||||
working site, it is a site with no headroom left for the next load step.
|
||||
2. At a load it cannot cover, **the weights stop working entirely**. The
|
||||
weighted arm and the shipped arm command the identical +2000 W into the empty
|
||||
pack, because `_fill()`'s last-resort pass hands the remainder to the
|
||||
zero-weighted unit. Same shares, same watts, same error.
|
||||
3. **And the envelope does not rescue the delivery either - it names the
|
||||
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
|
||||
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
|
||||
of site target is *reported* as unplaceable instead of appearing to have been
|
||||
placed.
|
||||
|
||||
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
|
||||
> this add-on shifts a unit's load to its sibling once the sibling is at its
|
||||
> rating, and weighting a failing unit down is not a remedy for it.
|
||||
|
||||
## 2b. ⚠️ The card's own premise is only half true
|
||||
|
||||
The card opens with *"a unit that stops delivering its share (empty, full,
|
||||
curtailed, wedged) parks the site with a standing grid error and no alarm."*
|
||||
After this branch: the **ask** stops, and while the sibling has headroom the site
|
||||
regulates to zero error instead of winding to twice the load. The **standing grid
|
||||
error does not stop** once the sibling is at its rating - §2's table measures
|
||||
1000 W standing in all three arms - and there is **still no alarm** for it, only
|
||||
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
|
||||
acceptance criteria and its evidence is that the benign and the wedged case are
|
||||
indistinguishable in the power domain.
|
||||
|
||||
So the two cards together mean: an empty pack limiting the site is now REPORTED
|
||||
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
|
||||
should be alarmed is a decision - and the alarm it would need is about a site's
|
||||
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
|
||||
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
|
||||
half meets.
|
||||
|
||||
## 3. Which floor, and which ceiling - the two places this could have become policy
|
||||
|
||||
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
|
||||
function's conjunction with `peak_soc_floor` is right for the peak guard and
|
||||
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
|
||||
the peak guard **only**, ordinary self-consumption discharges straight past it,
|
||||
and closing an envelope at it would turn an operator preference into a hard stop
|
||||
on the grid-following loop - the memo's **deferred Option B**, built by accident.
|
||||
`delivery.py`'s `account()` made the same choice one step out, for the same
|
||||
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
|
||||
EMS floor would have closed it.
|
||||
|
||||
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
|
||||
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
|
||||
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
|
||||
The only other number available is `arbitrage_soc_ceiling`, which is one
|
||||
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
|
||||
be the mistake above in the charge direction. So the envelope closes only where
|
||||
the ask is physically impossible, and everything between is left to the weights -
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
## 4. N=1, and the tripwire this had to argue past
|
||||
|
||||
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
|
||||
interval filed there would bound the SITE TARGET rather than a share. `main.py`
|
||||
therefore does not file it at all below a roster of two - the same guard, for the
|
||||
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
|
||||
byte-identical") is that `if`.
|
||||
|
||||
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
|
||||
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
|
||||
`pack` is state-of-charge-conditioned, so the argument is written at that set
|
||||
rather than the count edited: the number is not an option, the claim is
|
||||
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
|
||||
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
|
||||
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
|
||||
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
|
||||
claims, so a unit-scoped claim is the shape that sentence asks for.
|
||||
|
||||
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
|
||||
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
|
||||
deferred option, and the BMS is what stops it.
|
||||
|
||||
## 5. SAFETY-42 - the same hazard from the opposite side
|
||||
|
||||
The seam is **the state of charge** - and they overlap in one direction, which
|
||||
is what the rest of this section is about:
|
||||
|
||||
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
|
||||
* **SAFETY-42 alarms** when a pack that said it could does not.
|
||||
|
||||
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
|
||||
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
|
||||
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
|
||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
Measured on the closed loop - real `compute()`, real `plan()`, real
|
||||
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
|
||||
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
|
||||
positive** by that card's own definition:
|
||||
|
||||
load 1500 W equal weights (shipped) named on 199/200 cycles
|
||||
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
|
||||
load 1500 W OPT-09 weights named on 0/200 cycles
|
||||
shares {a:1505, b: 125} grid -5 W named=(nobody)
|
||||
|
||||
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
|
||||
exist** (expected ~2026-09-12): it is two copies of A driven with different
|
||||
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
|
||||
serial. The claim is about the arithmetic of the split and the threshold, which
|
||||
needs no hardware, and not about a GW5000S-BP.
|
||||
|
||||
At a load the healthy pack can cover - the regime where nothing else tells the
|
||||
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
|
||||
fires - a wedged inverter goes from named on essentially every cycle to **never
|
||||
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
|
||||
a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b grades all of it and **pins the current,
|
||||
unflattering behaviour**, so that the day it changes a named check moves. The
|
||||
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
|
||||
card **SAFETY-43**.
|
||||
|
||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
||||
detection window for a low-headroom unit.** The two cards do not merely meet from
|
||||
opposite sides; this one takes something away from the other one. Closing the gap
|
||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
||||
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
|
||||
discriminator does not exist": at two inverters, a wedged unit with materially
|
||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
||||
|
||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
|
||||
re-allocation is what would make acting safe, and the allocator has no way to
|
||||
express it today"*. Passing weights **is** that expression. Wiring delinquency
|
||||
into them is deliberately **not** done here: it would be acting on the alarm,
|
||||
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
|
||||
dropped than kept, and no instrument this add-on has can tell it from a wedged
|
||||
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
|
||||
a card, and this paragraph is its input.
|
||||
|
||||
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
|
||||
|
||||
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
|
||||
site would buy at the sum of the ratings whatever either pack's state of charge
|
||||
was. It is now sized to the packs whose own SoC permits that direction
|
||||
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
|
||||
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
|
||||
pack's.
|
||||
|
||||
Two honest limits:
|
||||
|
||||
* **The sell side is a no-op on every roster today.** `decide()` is handed
|
||||
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
|
||||
*any* pack is at or under the floor, i.e. in exactly the states where the sum
|
||||
could be smaller than the site rating. The arithmetic is graded directly
|
||||
(`test_packshare.py` part 9); it becomes reachable the day that gate is
|
||||
per-pack.
|
||||
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
|
||||
would let the trade proceed while the emptiest pack was below
|
||||
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
|
||||
of that pack is the weights - which are a preference, and `_fill()` will hand
|
||||
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
|
||||
WON the tick, which the arbiter's partition does not express. §2 is why that is
|
||||
a real objection and not a hedge.
|
||||
|
||||
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
|
||||
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
|
||||
the loop - the failure the note on `claim()` is already written against. The
|
||||
sizing can never be zero on a tick that trades today; that is a coincidence of
|
||||
the existing gate, and this makes it structural instead.
|
||||
|
||||
## 7. The fixture, which is the hard part
|
||||
|
||||
Two packs that differ in only ONE of SoC and capacity cannot separate a
|
||||
SoC-aware weighting from a capacity-aware one; two identical packs cannot
|
||||
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
|
||||
fixture itself before anything is asserted with it - four candidate schemes
|
||||
(equal, power rating, SoC only, energy), four distinct splits of the same
|
||||
1000 W:
|
||||
|
||||
equal A +500, B +500
|
||||
power A +521, B +479
|
||||
soc only A +125, B +875
|
||||
energy A +97, B +903
|
||||
|
||||
and the counterfactuals: on two identical packs all four collapse to one answer;
|
||||
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
|
||||
`energy` collapses onto `soc only`.
|
||||
|
||||
## 8. Provenance of every number
|
||||
|
||||
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
|
||||
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
|
||||
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
|
||||
not exist** (expected ~2026-09-12).
|
||||
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
|
||||
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
|
||||
is **inverter A copied** with `measured=False`, so any two-inverter simulation
|
||||
result anywhere in this project is **two copies of A**.
|
||||
* No rig was taken, no hardware was touched, and nothing here is evidence about
|
||||
a second GoodWe inverter.
|
||||
|
||||
## 9. Config
|
||||
|
||||
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
|
||||
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
|
||||
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
|
||||
be *defaulted*, and one default for two batteries protects the shallower pack
|
||||
with the deeper pack's number, whereas a missing capacity has an honest answer -
|
||||
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
|
||||
|
||||
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
|
||||
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
|
||||
comparison, `weights()` answers "no opinion" for the whole roster in that case,
|
||||
and a key that silently changes nothing is worse than one that refuses to load.
|
||||
|
||||
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
|
||||
split to weight, so it would be an option an operator can set and nothing reads.
|
||||
@@ -0,0 +1,207 @@
|
||||
# UX-02 — two configuration surfaces, one blob, and which one is authoritative
|
||||
|
||||
**Status: DECIDED 2026-09-06 — the STORED OPTIONS BLOB is authoritative and
|
||||
neither user interface is.** The ingress panel is the surface an operator
|
||||
should normally use, because it is the only one that can show the add-on's
|
||||
cross-field refusals *before* a save. The Supervisor's Configuration tab stays,
|
||||
unchanged, and is the **recovery** surface: it is the only one of the two that
|
||||
still exists when the add-on is not running.
|
||||
|
||||
Filed with UX-02. Card 6a9afc52d4e3662463274e9d. Depends on [[UX-01]], which is
|
||||
not superseded by this — see §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
The owner asked, 2026-09-04: *"can't we use some kind of webUI in HA to have a
|
||||
cleaner config with custom styling?"* Yes — `config.yaml` already declares
|
||||
`ingress: true`, `ingress_port: 8099` and `hassio_api: true`, and the panel is
|
||||
live. But building a second editor for the same settings creates a question the
|
||||
first one never had: **when two surfaces can write one blob, which one is
|
||||
right?**
|
||||
|
||||
Getting that wrong is not a cosmetic problem. `POST /addons/self/options`
|
||||
*replaces* the stored user options, so a page that writes a blob it read five
|
||||
minutes ago silently reverts whatever the other surface did in between.
|
||||
|
||||
## 2. What was measured, not assumed
|
||||
|
||||
All of it out of `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` —
|
||||
the version this site runs — by `tools/supervisor_selfapi_probe.py`, which runs
|
||||
Supervisor's own code rather than re-reading it. It touches nothing: no
|
||||
Supervisor is contacted, no options are posted and nothing is restarted.
|
||||
|
||||
**(a) An add-on may read and write its own options, and needs no role.**
|
||||
`_V1_PATTERNS.api_bypass` was run against the exact paths. `/addons/self/info`,
|
||||
`/addons/self/options` and `/addons/self/restart` all match; `security` and
|
||||
`update` do not. `token_validation()` tests that bypass *before* it consults
|
||||
`access_hassio_api` or any role, so no `hassio_role` is involved.
|
||||
|
||||
Two results the card did not predict, and both changed the design:
|
||||
|
||||
* `/addons/self/options/validate` — Supervisor's **own** options validator — is
|
||||
**not** reachable: it is two path segments after `self`, so `[^/]+` cannot
|
||||
match it, and it is not one of the listed literals. There is exactly one
|
||||
validator an add-on can call from inside itself, and it is `app/validate.py`.
|
||||
Which is the one we want: Supervisor's knows only types and ranges.
|
||||
* `/addons/self/info` is admitted by the `default` role *as well as* by the
|
||||
bypass (`/.+/info`), while `/addons/self/options` and `/addons/self/restart`
|
||||
are admitted by the **bypass only**. The two *writing* paths rest on that one
|
||||
list. The first draft of the probe asserted that the role admitted none of
|
||||
the three, and it went red — which is why the split is written down here.
|
||||
|
||||
**(b) `POST …/options` replaces; an omitted key reverts to the config.yaml
|
||||
default.** `App.options` is `deepmerge(config defaults, persisted user
|
||||
options)`, and the setter is `persist[options] = deepcopy(value)`. Run on real
|
||||
objects: with `max_w: 4200` stored, a POST that simply omits `max_w` leaves the
|
||||
effective value at **2000**, config.yaml's default — not at 4200.
|
||||
|
||||
That is why the panel posts the **whole** blob it read, and why it refuses to
|
||||
post one built on a stale read.
|
||||
|
||||
**(c) Validation does not coerce.** The full merged blob goes through
|
||||
`AppOptions` unchanged, key for key. That is what makes the panel's *"stored
|
||||
but not running"* comparison honest: a difference shown is a difference that
|
||||
exists.
|
||||
|
||||
**(d) The Configuration tab *can* be removed — and doing so destroys every
|
||||
setting on the site.** The card said it cannot be. It can: `schema:` is
|
||||
optional, a manifest without it is accepted, and `schema_ui` is then `[]`, so
|
||||
the tab renders nothing. What goes with it is the add-on. `AppOptions` drops
|
||||
every option the schema does not declare — **0 of 71 survive** — so
|
||||
`/data/options.json` becomes `{}`, the controller starts with no actuator, the
|
||||
startup gate refuses it (exit 78), and the ingress panel goes down with the
|
||||
process serving it.
|
||||
|
||||
So the tab stays **by decision, not by constraint**. That distinction matters:
|
||||
a constraint nobody re-examines is not a decision, and this one has a cost
|
||||
(§4) that has to be paid knowingly.
|
||||
|
||||
## 3. The decision
|
||||
|
||||
> **The authoritative configuration is the options blob the Supervisor stores.
|
||||
> Both surfaces are editors of it. Neither is a cache of the other, and neither
|
||||
> owns a copy of anything.**
|
||||
|
||||
Consequences, in the order they bind:
|
||||
|
||||
1. **The panel never holds state.** Every page load re-reads
|
||||
`GET /addons/self/info`. There is no local copy of the options, the schema
|
||||
or the labels — the form is *built* from that response by
|
||||
`app/configui.py`, which contains no option key, no label and no rule.
|
||||
2. **A save is a compare-and-swap.** The read hands the page a
|
||||
`fingerprint()` (a sha256 of the canonical blob); the save hands it back;
|
||||
`selfconfig.save()` re-reads the stored blob **immediately before writing**
|
||||
and refuses if the digest moved. The window in which the other surface can
|
||||
slip a change past is one HTTP round trip, not the length of an editing
|
||||
session.
|
||||
3. **Validation happens before the compare, and a refused blob is never
|
||||
written.** Not warned about — not written.
|
||||
4. **Saving never restarts.** Applying options costs a control interruption
|
||||
(§5), so it is a separate, explicitly confirmed act.
|
||||
|
||||
### Why a digest and not a version number
|
||||
|
||||
The Supervisor exposes no revision or ETag for an add-on's options. The only
|
||||
thing a page can hold between reading and writing is the *content* it read —
|
||||
and that is enough, because the question is not "how many times has this
|
||||
changed" but "is what I am about to overwrite still what I was shown".
|
||||
Identical content is by definition safe to overwrite.
|
||||
|
||||
### This is [[SAFETY-28]]'s discipline, applied to configuration
|
||||
|
||||
`arbiter.py` and `Controller.write_refusal()` enforce *exactly one writer per
|
||||
actuator* not by convention but by a check on the write path:
|
||||
|
||||
> A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS
|
||||
> TICK, FOR THIS UNIT.
|
||||
|
||||
The configuration analogue is written the same way and for the same reason:
|
||||
|
||||
> A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM THE BLOB THAT IS
|
||||
> STORED RIGHT NOW.
|
||||
|
||||
Note what is *not* claimed. SAFETY-28's rule makes a second writer
|
||||
inexpressible; this one does not — the Supervisor's tab is a second writer and
|
||||
stays one. What this gets is the weaker, honest property: **a second writer is
|
||||
detected rather than silently overwritten.** That is the most a
|
||||
compare-and-swap can give, and pretending otherwise would be the "check that
|
||||
observes something other than what it claims to observe" this repository keeps
|
||||
finding.
|
||||
|
||||
## 4. What this costs, stated plainly
|
||||
|
||||
**Two surfaces means two places to be confused.** The Configuration tab still
|
||||
renders all 71 keys as a flat list, and it can still store a configuration the
|
||||
add-on will refuse to start on — it knows only types and ranges. UX-01's
|
||||
translations are therefore **not** superseded by this card: they are the only
|
||||
thing standing between that tab and raw key names, and they are also where this
|
||||
panel's labels, descriptions and section headings come from. The two surfaces
|
||||
share one set of words on purpose; two vocabularies for one setting would be
|
||||
worse than one plain list.
|
||||
|
||||
**The panel is not available when it is most needed.** If a configuration that
|
||||
refuses to start is ever stored — from the Configuration tab, or by a hand-edit,
|
||||
or by this panel in some future state it does not currently permit — the add-on
|
||||
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.
|
||||
|
||||
**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.
|
||||
When ESPHome is asleep the range is `None` and those two rules are skipped —
|
||||
here and at startup, identically. The page says so rather than looking green
|
||||
for a reason the operator cannot see.
|
||||
|
||||
## 5. The restart cost, and why it is a separate button
|
||||
|
||||
Applying options needs a restart, and `Controller.shutdown()` commands **0 W on
|
||||
every inverter** on the way out ("shutting down - commanding 0 W on every
|
||||
inverter", main.py). A configuration save is therefore a real, if brief, loss
|
||||
of control of the battery — and the operator is told that *before* they commit,
|
||||
in the page's own markup, not in a message that appears afterwards.
|
||||
|
||||
So the panel splits the act in two:
|
||||
|
||||
* **Save** stores the blob. Nothing restarts, nothing is applied, and the page
|
||||
then says which settings are stored but not running.
|
||||
* **Restart to apply** asks again, names the 0 W again, and only then posts
|
||||
`/addons/self/restart`. The request carries an intent token, so a reload, a
|
||||
retry or a stray POST cannot spend a control interruption on the operator's
|
||||
behalf.
|
||||
|
||||
A restart does **not** clear anything: SAFETY-02's latches are restored from
|
||||
the Store on the way back up, which is what `Inverter.__init__` is written to
|
||||
do and what [[SAFETY-38]] depends on.
|
||||
|
||||
## 6. The scope line, and it is a safety line
|
||||
|
||||
**Nothing on this surface commands an inverter, clears a latch or bypasses a
|
||||
safety rule.** The set of things an operator can do to the hardware through the
|
||||
panel is exactly what `/action` offered before this card. `app/selfconfig.py`
|
||||
talks to the Supervisor about a JSON blob and to `validate.py` about whether
|
||||
that blob is startable; the only actuator-shaped verb in it is `restart()`,
|
||||
which restarts *this add-on* and whose 0 W is the existing, documented
|
||||
wind-down.
|
||||
|
||||
test_selfconfig.py part 7 asserts that mechanically — it lists the routes, and
|
||||
it greps `selfconfig.py` and the page for `set_number`, `write_setpoint`,
|
||||
`handle_action`, `command_site_zero`, `sign_wd` and `reset(`. Adding "and also
|
||||
set the setpoint while we are here" would put a second writer on the actuator
|
||||
through the configuration surface, which is the non-goal this add-on is built
|
||||
around.
|
||||
|
||||
## 7. How "every message comes from the shipped validator" is proven
|
||||
|
||||
Not by inspection. `test_selfconfig.py` part 6 copies the package, inserts **one
|
||||
new rule** into `validate_startup_config()` in the copy, hashes every file under
|
||||
`app/` to show that **validate.py is the only one that differs**, and then
|
||||
serves the real `/config/validate` route out of that copy in a subprocess. The
|
||||
new sentence arrives on the page, filed under the option key it names, with no
|
||||
UI file touched.
|
||||
|
||||
That is the acceptance criterion run rather than argued, and it is what makes
|
||||
"do not reimplement any rule in JavaScript" a property of the build instead of
|
||||
a promise in a comment.
|
||||
+121
-44
@@ -40,6 +40,69 @@ hardware holds it there.
|
||||
|
||||
## Configuration
|
||||
|
||||
### ⚠️ There are TWO places to configure this add-on (UX-02)
|
||||
|
||||
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
||||
there is one options blob, and neither surface is a copy of it.
|
||||
|
||||
| | Ingress panel → **Configuration** | Supervisor → **Configuration** tab |
|
||||
|---|---|---|
|
||||
| **Use it for** | everyday editing | recovery, and when the add-on is down |
|
||||
| **Sections, units, help** | real headings, units beside the boxes, the description under each field | one flat list of 71 fields, no headings, no units |
|
||||
| **Tells you a setting is wrong** | **yes — before you save**, next to the field | no. It knows types and ranges only |
|
||||
| **Can express a rule spanning two keys** | yes, because it runs the add-on's own validator | **no.** The schema grammar cannot say "must be at or below `max_w`" |
|
||||
| **Available when the add-on is not running** | **no** — it is served *by* the add-on | **yes** |
|
||||
| **Editing the inverter list** | cards you add and remove, with the per-entry labels rendered | a repeatable section whose per-entry keys render **raw** on frontend 20250925.1 |
|
||||
|
||||
**Which one is authoritative: neither.** The stored options blob is, and both
|
||||
are editors of it. `DECISIONS/UX-02-two-config-surfaces.md` is the record, with
|
||||
the measurements behind it.
|
||||
|
||||
The one thing you need to know in practice: **the panel refuses to save a
|
||||
configuration the add-on would not start on, and the Configuration tab does
|
||||
not.** The tab will happily store `maintenance_charge_w: 2500` beside
|
||||
`max_w: 2000`; the add-on then exits 78 on its next start, the panel goes down
|
||||
with it, and the tab is where you fix it. That asymmetry is the reason both
|
||||
exist.
|
||||
|
||||
#### What the panel does when you press Save
|
||||
|
||||
1. It runs the **shipped** validator — the same `validate_startup_config()` and
|
||||
`validate_unit_ranges()` the add-on runs at startup, not a copy of them — and
|
||||
if anything is refused, **nothing is written** and your edits stay on screen.
|
||||
2. It re-reads the stored blob and refuses if it changed while you were editing
|
||||
— someone in the Configuration tab, or you on another device. It names the
|
||||
keys that moved and shows both values; pressing Save again then writes yours.
|
||||
3. It writes the whole blob. It does **not** restart: `POST …/options` replaces
|
||||
the stored options, and a key left out would revert to the shipped default,
|
||||
so the panel always sends everything it read.
|
||||
|
||||
#### ⚠️ Saving costs nothing. Applying costs a control interruption
|
||||
|
||||
Stored settings take effect when the add-on next starts. The panel shows which
|
||||
stored settings are not what the add-on is currently running, and **Restart to
|
||||
apply** is a separate button with its own confirmation — because the wind-down
|
||||
commands **0 W on every inverter** before the process exits, and the battery
|
||||
does nothing until it is back. Nothing else is changed by that restart: a
|
||||
latched sign-disagreement failsafe is restored from the Store on the way back
|
||||
up, not cleared.
|
||||
|
||||
#### Two range checks can be skipped, and the page says so
|
||||
|
||||
Two of the refusals compare a configured ceiling against the setpoint device's
|
||||
**own** reported range, which means asking Home Assistant. If ESPHome has not
|
||||
come up, that range is unknown and those two checks are skipped — on the page
|
||||
and at startup, identically. The page prints a note saying so rather than
|
||||
looking green for a reason you cannot see. If the device answers later and
|
||||
disagrees, the add-on refuses to start then, with the same sentence.
|
||||
|
||||
#### On a phone
|
||||
|
||||
One column, big targets, sections collapsed until you open them, and the
|
||||
refusals pinned where you cannot scroll past them. It is the screen for
|
||||
standing in front of the inverter.
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
@@ -52,7 +115,7 @@ whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **All 70 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||
@@ -73,6 +136,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
|
||||
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
@@ -98,11 +162,13 @@ inverters:
|
||||
soc_entity: sensor.goodwe_a_soc
|
||||
batt_entity: sensor.goodwe_a_battery_power
|
||||
max_w: 5000
|
||||
capacity_kwh: 15.36
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 4600
|
||||
capacity_kwh: 20.48
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
@@ -166,6 +232,54 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
#### How the site target is divided between the packs (OPT-09)
|
||||
|
||||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||||
do** — that is the defect this section describes the fix for, so if the add-on
|
||||
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
|
||||
the split is driven by two separate things, and it is worth knowing which is
|
||||
which, because only one of them is a rule:
|
||||
|
||||
* **The envelope — a permission.** A pack whose own state of charge is at or
|
||||
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
|
||||
is **not asked to charge**. Its share goes to the sibling, inside that
|
||||
sibling's own rating. This is absolute: the allocator cannot exceed it.
|
||||
* **The weights — a preference.** Between those limits, each pack is asked for
|
||||
the fraction of the site target that matches its **energy headroom**: the kWh
|
||||
it has above its own cut-out when the site is discharging, the kWh it has
|
||||
below full when the site is charging. That is what `capacity_kwh` is for.
|
||||
|
||||
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
|
||||
everywhere and the add-on has no opinion about the split — the target is divided
|
||||
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
|
||||
pack measured in kWh beside one that is not is not a comparison, and the add-on
|
||||
refuses to start rather than ignore half of what you typed.
|
||||
|
||||
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
|
||||
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
|
||||
about a third more energy and about 8 % less power. Dividing by rating hands the
|
||||
work to A; dividing by energy hands it to B. The question a split is answering is
|
||||
"which pack can sustain its share for the hour", so it is the energy one. Each
|
||||
inverter's own rating still binds its own share, separately, through `max_w`.
|
||||
|
||||
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
|
||||
choice about which pack does the work. They are **not** a way to make the site
|
||||
deliver. A unit that is at its energy limit — or that has simply stopped
|
||||
responding — does not hand its load to its sibling: the loop sees the shortfall,
|
||||
freezes, and the site runs short by that unit's share. Weighting a failing unit
|
||||
down does not fix that, because when the healthy unit reaches its own rating the
|
||||
allocator hands the remainder to the down-weighted one anyway rather than leave
|
||||
watts unplaced. What you get instead is honesty: the action log reports the watts
|
||||
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
|
||||
placed on any inverter`), and a unit that accepts its share and does not produce
|
||||
it is named by the delivery alarm (SAFETY-42, above).
|
||||
|
||||
⚠️ **At one inverter none of this applies and nothing changed.** There is no
|
||||
split to weight, `capacity_kwh` is not read, and no envelope is closed —
|
||||
self-consumption still discharges down to the battery's own cut-out exactly as
|
||||
it always has.
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
@@ -1446,52 +1560,15 @@ is, and a **Commissioning** checklist that names any problem in words. It also
|
||||
carries the three buttons: start/stop control, force a maintenance cycle, and
|
||||
abort one.
|
||||
|
||||
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
|
||||
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
|
||||
inverter.** The configuration page reads and writes the add-on's own settings
|
||||
and can restart the add-on; the set of commands that reach the hardware is
|
||||
exactly the three buttons it always had.
|
||||
|
||||
## Status entities
|
||||
|
||||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||||
battery power, circulating power, state of charge, maintenance phase and
|
||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||
broker, and MQTT problems can never affect control.
|
||||
|
||||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||||
|
||||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||
reports as success:
|
||||
|
||||
**The silent no-op.** The Supervisor keys the built image by `version:` in
|
||||
`config.yaml`. `ha apps update` against an unchanged version installs nothing
|
||||
and says it worked. This happened twice: PKG-06 (four merges behind an unbumped
|
||||
version) and PKG-07 (six). Both times a human noticed. Nothing else would have.
|
||||
|
||||
**The refused config.** A config the shipped validator rejects is
|
||||
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
|
||||
with it**, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
|
||||
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
Run it inside the container, so the validator half actually runs:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||
reachable through the Home Assistant REST API, and no entity carries it.
|
||||
|
||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||
add-on's dependencies the validator half skips and returns 2 rather than
|
||||
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
|
||||
because the first draft of that test reported four silent skips as passes.
|
||||
|
||||
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
|
||||
runtime code must bump the version" — is the wrong shape here. This project
|
||||
deliberately batches many merges into one deploy, so a per-PR bump would put
|
||||
every branch in conflict over one line and still not prevent the failure, which
|
||||
happens at deploy and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
|
||||
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
|
||||
for a typo fix trains people to bump without reading — the habit the check
|
||||
exists to prevent.
|
||||
|
||||
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
|
||||
accept a callable of the site target, because an energy-headroom
|
||||
weighting is direction-dependent and the direction is stage 2's
|
||||
answer; `resolve()` calls it and hands the RESULT here. Keeping
|
||||
that out of this module is what keeps `plan()` a pure function of
|
||||
numbers and intervals: a callback invoked inside the fill would
|
||||
put a caller's code, and whatever it reads, back into the module
|
||||
whose whole discipline is that it reads nothing.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
|
||||
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
|
||||
# At N=1 the two are the same comparison and this claim is the one filed,
|
||||
# exactly as it shipped.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
|
||||
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
|
||||
# where it BINDS and only at a roster of two or more. It says "this battery
|
||||
# cannot do that", which is a fact about one cupboard in the same way
|
||||
# `device`'s rating is a fact about one box, so it is scoped like `device`
|
||||
# and filed at the same priority.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
|
||||
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
|
||||
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
|
||||
# stopping the grid-following loop at a depth somebody picked. This is the
|
||||
# pack's own BMS cut-out, which is measured off register 0x0560 and required
|
||||
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
|
||||
# is deliberately NOT consulted, and the SITE target is not touched - only
|
||||
# which unit carries it. Part 10f's own note names the revisit trigger:
|
||||
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
|
||||
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
|
||||
# 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.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
|
||||
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
|
||||
A CALLABLE as well as a mapping: called with the resolved site target once
|
||||
stage 2 has run, because an energy-headroom weighting is direction-dependent
|
||||
and the direction is stage 2's answer. See the call site below. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
|
||||
# is the one line that makes SoC-aware weighting expressible at all.
|
||||
#
|
||||
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
|
||||
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
|
||||
# site is discharging, room below full when it is charging - and the two
|
||||
# give opposite answers on the same roster. The site target is chosen by
|
||||
# stage 2, three lines up, so a caller computing weights before `resolve()`
|
||||
# would have to guess which way the site is about to go, and would be wrong
|
||||
# in one of the two directions on every tick.
|
||||
#
|
||||
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
|
||||
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
|
||||
# which is what keeps it a pure function of numbers and intervals: a
|
||||
# callback invoked inside the fill would put a caller's code - and whatever
|
||||
# it reads - back into the module whose whole discipline is that it reads
|
||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||
# own direction can differ from the target's sign under a pin, in which case
|
||||
# the weighting is a worse preference and still cannot be unsafe).
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
|
||||
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
# a caller can hand this function a zero. A zero must not reach `claim()`: a
|
||||
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
|
||||
# which is the failure the note on `claim()` is written against - it would
|
||||
# silently disable self-consumption for the whole hour. So the sizing is checked
|
||||
# where the decision is made rather than trusted to arrive positive.
|
||||
IDLE_NO_SIZE = (
|
||||
"no pack's state of charge permits this side of the trade, so it is sized "
|
||||
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""UX-02: the configuration page itself - markup, style and the form's script.
|
||||
|
||||
⚠️ WHY THIS IS ITS OWN FILE. `web.py` is the ROUTES: three status handlers and,
|
||||
since this card, five configuration ones. The page is ~500 lines of HTML, CSS
|
||||
and JavaScript, and inlining it there would bury the routing - which is the
|
||||
half a reviewer has to check for "does anything here command an inverter".
|
||||
|
||||
⚠️ AND WHAT IS NOT IN THIS FILE, WHICH IS THE POINT OF THE CARD. There is no
|
||||
option key, no label, no unit, no range, no default and NO RULE in the script
|
||||
below. It renders a model the server built out of `GET /addons/self/info`
|
||||
(app/configui.py) and it renders refusals the server got out of
|
||||
`validate_startup_config()` (app/selfconfig.py). Its longest function is the one
|
||||
that decides which HTML input a `type` deserves.
|
||||
|
||||
That is deliberate and it is checkable: test_selfconfig.py part 5 greps this
|
||||
file for the option keys and for phrases out of validate.py's refusals and
|
||||
asserts it contains none of them, and part 6 adds a NEW rule to a copy of
|
||||
validate.py and watches it appear on the page with this file untouched.
|
||||
|
||||
⚠️ EVERY URL IS RELATIVE. Ingress serves the add-on under a generated path
|
||||
prefix (`/api/hassio_ingress/<token>/`), so an absolute `/config/model` 404s in
|
||||
the field while working perfectly on a laptop. web.py's own header has said so
|
||||
since the status page shipped; this file has five more chances to get it wrong.
|
||||
|
||||
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
|
||||
front of the inverter with: one column, big touch targets, sections that start
|
||||
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
|
||||
where it cannot be scrolled past.
|
||||
"""
|
||||
|
||||
CONFIG_PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GoodWe - Configuration</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
||||
--ok:#137333;--warn:#b25e02;--bad:#c5221f;--accent:#1a73e8;
|
||||
--badtint:rgba(197,34,31,.10);--warntint:rgba(178,94,2,.10);
|
||||
--oktint:rgba(19,115,51,.10)}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
||||
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82;
|
||||
--accent:#8ab4f8;--badtint:rgba(242,139,130,.14);
|
||||
--warntint:rgba(253,214,99,.14);--oktint:rgba(129,201,149,.14)}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:0 0 96px;background:var(--bg);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
h1{font-size:18px;margin:0 0 2px}
|
||||
a{color:var(--accent)}
|
||||
.sub{color:var(--muted);font-size:13px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px;margin-bottom:12px}
|
||||
.note{border-left:4px solid var(--warn);background:var(--warntint);
|
||||
border-radius:8px;padding:11px 13px;margin-bottom:12px;font-size:14px}
|
||||
.note.bad{border-left-color:var(--bad);background:var(--badtint)}
|
||||
.note.ok{border-left-color:var(--ok);background:var(--oktint)}
|
||||
.note b{display:block;margin-bottom:3px}
|
||||
details.grp{background:var(--card);border:1px solid var(--line);
|
||||
border-radius:10px;margin-bottom:10px;overflow:hidden}
|
||||
details.grp>summary{cursor:pointer;padding:14px;font-weight:600;
|
||||
list-style:none;display:flex;justify-content:space-between;gap:10px;
|
||||
align-items:center}
|
||||
details.grp>summary::-webkit-details-marker{display:none}
|
||||
details.grp>summary::after{content:"\\25be";color:var(--muted);font-weight:400}
|
||||
details.grp[open]>summary::after{content:"\\25b4"}
|
||||
details.grp[open]>summary{border-bottom:1px solid var(--line)}
|
||||
.gbody{padding:4px 14px 14px}
|
||||
.pill{font-size:12px;font-weight:600;border-radius:999px;padding:2px 9px;
|
||||
background:var(--badtint);color:var(--bad)}
|
||||
.pill.q{background:var(--warntint);color:var(--warn)}
|
||||
.f{padding:13px 0;border-bottom:1px solid var(--line)}
|
||||
.f:last-child{border-bottom:0}
|
||||
.f>label{display:block;font-weight:600;margin-bottom:2px}
|
||||
.k{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
|
||||
.row{display:flex;gap:8px;align-items:center;margin:6px 0 0}
|
||||
.row input[type=text],.row input[type=number],.row select{flex:1 1 auto;
|
||||
min-width:0;font:inherit;padding:10px;border-radius:8px;
|
||||
border:1px solid var(--line);background:var(--bg);color:var(--ink)}
|
||||
.row .unit{color:var(--muted);font-size:13px;white-space:nowrap}
|
||||
.rng{color:var(--muted);font-size:12px;margin-top:4px}
|
||||
.help{color:var(--muted);font-size:13px;margin-top:6px;cursor:pointer;
|
||||
max-height:3.1em;overflow:hidden;position:relative}
|
||||
.help.open{max-height:none}
|
||||
.help::after{content:" more";color:var(--accent)}
|
||||
.help.open::after{content:" less";color:var(--accent)}
|
||||
.prob{margin-top:8px;border-left:4px solid var(--bad);background:var(--badtint);
|
||||
border-radius:6px;padding:9px 11px;font-size:13.5px}
|
||||
.prob+.prob{margin-top:6px}
|
||||
.sw{display:flex;align-items:center;gap:10px;margin-top:6px}
|
||||
.sw input{width:20px;height:20px}
|
||||
.entry{border:1px solid var(--line);border-radius:9px;padding:10px 12px;
|
||||
margin:10px 0;background:var(--bg)}
|
||||
.entry h4{margin:0 0 2px;font-size:14px}
|
||||
button{font:inherit;padding:10px 14px;border-radius:8px;
|
||||
border:1px solid var(--line);background:var(--card);color:var(--ink);
|
||||
cursor:pointer}
|
||||
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
||||
button[disabled]{opacity:.5;cursor:not-allowed}
|
||||
button.small{padding:6px 10px;font-size:13px}
|
||||
.bar{position:fixed;left:0;right:0;bottom:0;background:var(--card);
|
||||
border-top:1px solid var(--line);padding:10px 14px;z-index:5}
|
||||
.bar .in{max-width:760px;margin:0 auto;display:flex;gap:10px;
|
||||
align-items:center;flex-wrap:wrap}
|
||||
.bar .msg{flex:1 1 200px;min-width:0;font-size:13px;color:var(--muted)}
|
||||
dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);
|
||||
color:var(--ink);max-width:520px;width:92%;padding:18px}
|
||||
dialog::backdrop{background:rgba(0,0,0,.5)}
|
||||
.diff{font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
white-space:pre-wrap;word-break:break-word;margin-top:6px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<h1>GoodWe - Configuration</h1>
|
||||
<p class="sub"><a href="./">← Status and controls</a></p>
|
||||
|
||||
<div class="note" id="authority">
|
||||
<b>This page and the Supervisor's Configuration tab edit the same settings.</b>
|
||||
Both write one options blob. This page is the one that can tell you a
|
||||
setting is wrong <i>before</i> you save it; the Configuration tab cannot,
|
||||
because it only knows types and ranges. If the add-on is not running, the
|
||||
Configuration tab is the only one of the two that still exists - that is
|
||||
what it is for.
|
||||
</div>
|
||||
|
||||
<div class="note" id="restartcost">
|
||||
<b>Saving does not restart anything, and does not take effect on its own.</b>
|
||||
Stored settings are applied when the add-on next starts. Restarting to
|
||||
apply them costs a short loss of control: the add-on commands
|
||||
<b>0 W on every inverter</b> as it shuts down, and the battery does nothing
|
||||
until it is back up. That is a separate button, below, with its own
|
||||
confirmation.
|
||||
</div>
|
||||
|
||||
<div id="banner"></div>
|
||||
<div id="staleness"></div>
|
||||
<div id="form"></div>
|
||||
</div>
|
||||
|
||||
<div class="bar"><div class="in">
|
||||
<button class="primary" id="save" disabled>Save settings</button>
|
||||
<button id="restart" disabled>Restart to apply</button>
|
||||
<button id="reload">Discard edits</button>
|
||||
<span class="msg" id="msg">loading…</span>
|
||||
</div></div>
|
||||
|
||||
<dialog id="confirm">
|
||||
<h3 style="margin-top:0">Restart the add-on?</h3>
|
||||
<p>The add-on will command <b>0 W on every inverter</b>, stop, and start
|
||||
again with the stored settings. Control of the battery is lost for as long as
|
||||
that takes. Nothing else about the inverter is changed, and no latched
|
||||
failsafe is cleared.</p>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||||
<button id="cancel">Cancel</button>
|
||||
<button class="danger" id="go">Yes, restart now</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
var MODEL = null; // groups/fields, from the server
|
||||
var STORED = null; // the options blob the server last read
|
||||
var FP = ""; // its fingerprint - the compare-and-swap token
|
||||
var VALS = {}; // what is on screen right now
|
||||
var timer = null;
|
||||
|
||||
function el(tag, cls, text){
|
||||
var e = document.createElement(tag);
|
||||
if(cls) e.className = cls;
|
||||
if(text !== undefined && text !== null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
function msg(text, kind){
|
||||
var m = document.getElementById('msg');
|
||||
m.textContent = text;
|
||||
m.style.color = kind === 'bad' ? 'var(--bad)'
|
||||
: kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
|
||||
}
|
||||
function note(host, kind, title, body){
|
||||
var d = el('div', 'note ' + (kind || ''));
|
||||
if(title) d.appendChild(el('b', null, title));
|
||||
if(body) d.appendChild(document.createTextNode(body));
|
||||
host.appendChild(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// --- the value of one input, by the type the SERVER gave the field ---------
|
||||
// The only "knowledge" in this script: which HTML control a Supervisor type
|
||||
// deserves, and how to read it back. No key names, no bounds, no rules.
|
||||
function readInput(input, type){
|
||||
if(type === 'boolean') return input.checked;
|
||||
if(type === 'integer' || type === 'float'){
|
||||
if(input.value === '') return '';
|
||||
var n = Number(input.value);
|
||||
return isNaN(n) ? input.value : n;
|
||||
}
|
||||
return input.value;
|
||||
}
|
||||
|
||||
function makeInput(field, value, onchange){
|
||||
var row = el('div', 'row');
|
||||
var input;
|
||||
if(field.type === 'boolean'){
|
||||
row.className = 'sw';
|
||||
input = el('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = !!value;
|
||||
var lab = el('span', 'k', field.key);
|
||||
row.appendChild(input); row.appendChild(lab);
|
||||
} else if(field.type === 'select'){
|
||||
input = el('select');
|
||||
(field.options || []).forEach(function(o){
|
||||
var opt = el('option', null, o); opt.value = o;
|
||||
if(String(value) === o) opt.selected = true;
|
||||
input.appendChild(opt);
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else {
|
||||
input = el('input');
|
||||
if(field.type === 'integer' || field.type === 'float'){
|
||||
input.type = 'number';
|
||||
input.inputMode = field.type === 'integer' ? 'numeric' : 'decimal';
|
||||
input.step = field.type === 'integer' ? '1' : 'any';
|
||||
if(field.min !== undefined) input.min = field.min;
|
||||
if(field.max !== undefined) input.max = field.max;
|
||||
} else {
|
||||
input.type = 'text';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
}
|
||||
input.value = (value === undefined || value === null) ? '' : value;
|
||||
row.appendChild(input);
|
||||
if(field.unit) row.appendChild(el('span', 'unit', field.unit));
|
||||
}
|
||||
input.addEventListener('input', onchange);
|
||||
input.addEventListener('change', onchange);
|
||||
return {row: row, input: input};
|
||||
}
|
||||
|
||||
function helpBlock(field){
|
||||
if(!field.help) return null;
|
||||
var h = el('div', 'help', field.help);
|
||||
h.title = field.help;
|
||||
h.addEventListener('click', function(){ h.classList.toggle('open'); });
|
||||
return h;
|
||||
}
|
||||
|
||||
function rangeHint(field){
|
||||
var bits = [];
|
||||
if(field.min !== undefined || field.max !== undefined){
|
||||
bits.push('accepted: ' + (field.min !== undefined ? field.min : '-')
|
||||
+ ' to ' + (field.max !== undefined ? field.max : '-')
|
||||
+ (field.unit ? ' ' + field.unit : ''));
|
||||
}
|
||||
bits.push(field.key);
|
||||
return el('div', 'rng', bits.join(' \\u00b7 '));
|
||||
}
|
||||
|
||||
// --- a list of scalars (`- str` in the schema) -----------------------------
|
||||
function scalarList(field, value){
|
||||
var box = el('div');
|
||||
var items = Array.isArray(value) ? value.slice() : [];
|
||||
function draw(){
|
||||
box.textContent = '';
|
||||
items.forEach(function(v, i){
|
||||
var row = el('div', 'row');
|
||||
var input = el('input'); input.type = 'text'; input.value = v;
|
||||
input.autocapitalize = 'none'; input.spellcheck = false;
|
||||
input.addEventListener('input', function(){
|
||||
items[i] = input.value; VALS[field.key] = items.slice(); queue();
|
||||
});
|
||||
var rm = el('button', 'small', 'Remove');
|
||||
rm.addEventListener('click', function(){
|
||||
items.splice(i, 1); VALS[field.key] = items.slice(); draw(); queue();
|
||||
});
|
||||
row.appendChild(input); row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
});
|
||||
var add = el('button', 'small', 'Add');
|
||||
add.addEventListener('click', function(){
|
||||
items.push(''); VALS[field.key] = items.slice(); draw(); queue();
|
||||
});
|
||||
box.appendChild(add);
|
||||
}
|
||||
draw();
|
||||
return box;
|
||||
}
|
||||
|
||||
// --- a list of objects: the roster editor ----------------------------------
|
||||
function objectList(field, value){
|
||||
var box = el('div');
|
||||
var entries = Array.isArray(value) ? JSON.parse(JSON.stringify(value)) : [];
|
||||
function push(){ VALS[field.key] = JSON.parse(JSON.stringify(entries)); }
|
||||
function draw(){
|
||||
box.textContent = '';
|
||||
if(!entries.length){
|
||||
var e = el('div', 'rng',
|
||||
'No entries. With none, the settings above describe the one inverter.');
|
||||
box.appendChild(e);
|
||||
}
|
||||
entries.forEach(function(entry, i){
|
||||
var card = el('div', 'entry');
|
||||
card.appendChild(el('h4', null, 'Entry ' + (i + 1)
|
||||
+ (entry && entry.name ? ' \\u2014 ' + entry.name : '')));
|
||||
(field.fields || []).forEach(function(sub){
|
||||
var f = el('div', 'f');
|
||||
// The label is the translation's own, verbatim - UX-01 already says
|
||||
// "(optional)" in the ones that are, and adding a second one here
|
||||
// would be this panel putting words next to a field.
|
||||
f.appendChild(el('label', null, sub.label));
|
||||
var made = makeInput(sub, entry ? entry[sub.key] : undefined,
|
||||
function(){
|
||||
entries[i][sub.key] = readInput(made.input, sub.type);
|
||||
push(); queue();
|
||||
});
|
||||
f.appendChild(made.row);
|
||||
f.appendChild(rangeHint(sub));
|
||||
var h = helpBlock(sub); if(h) f.appendChild(h);
|
||||
card.appendChild(f);
|
||||
});
|
||||
var rm = el('button', 'small danger', 'Remove this inverter');
|
||||
rm.addEventListener('click', function(){
|
||||
entries.splice(i, 1); push(); draw(); queue();
|
||||
});
|
||||
card.appendChild(rm);
|
||||
box.appendChild(card);
|
||||
});
|
||||
var add = el('button', 'small', 'Add an inverter');
|
||||
add.addEventListener('click', function(){
|
||||
var fresh = {};
|
||||
(field.fields || []).forEach(function(sub){
|
||||
fresh[sub.key] = sub.type === 'boolean' ? false
|
||||
: (sub.type === 'integer' || sub.type === 'float') ? ''
|
||||
: '';
|
||||
});
|
||||
entries.push(fresh); push(); draw(); queue();
|
||||
});
|
||||
box.appendChild(add);
|
||||
}
|
||||
draw();
|
||||
return box;
|
||||
}
|
||||
|
||||
// --- rendering the whole form ---------------------------------------------
|
||||
var FIELD_NODES = {};
|
||||
|
||||
function render(){
|
||||
var host = document.getElementById('form');
|
||||
host.textContent = '';
|
||||
FIELD_NODES = {};
|
||||
MODEL.groups.forEach(function(group){
|
||||
var d = el('details', 'grp');
|
||||
var s = el('summary');
|
||||
s.appendChild(el('span', null, group.name || 'Not grouped'));
|
||||
var pill = el('span', 'pill'); pill.style.display = 'none';
|
||||
s.appendChild(pill);
|
||||
d.appendChild(s);
|
||||
var body = el('div', 'gbody');
|
||||
group.fields.forEach(function(field){
|
||||
var f = el('div', 'f');
|
||||
f.appendChild(el('label', null, field.label));
|
||||
if(field.type === 'schema'){
|
||||
f.appendChild(objectList(field, VALS[field.key]));
|
||||
} else if(field.multiple){
|
||||
f.appendChild(scalarList(field, VALS[field.key]));
|
||||
} else {
|
||||
var made = makeInput(field, VALS[field.key], function(){
|
||||
VALS[field.key] = readInput(made.input, field.type); queue();
|
||||
});
|
||||
f.appendChild(made.row);
|
||||
}
|
||||
if(field.type !== 'schema') f.appendChild(rangeHint(field));
|
||||
var h = helpBlock(field); if(h) f.appendChild(h);
|
||||
var probs = el('div'); f.appendChild(probs);
|
||||
FIELD_NODES[field.key] = {probs: probs, group: d, pill: pill};
|
||||
body.appendChild(f);
|
||||
});
|
||||
d.appendChild(body);
|
||||
host.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// --- refusals: printed exactly as the validator wrote them -----------------
|
||||
function showProblems(payload){
|
||||
Object.keys(FIELD_NODES).forEach(function(k){
|
||||
FIELD_NODES[k].probs.textContent = '';
|
||||
});
|
||||
document.querySelectorAll('.pill').forEach(function(p){
|
||||
p.style.display = 'none'; p.textContent = '';
|
||||
});
|
||||
var banner = document.getElementById('banner');
|
||||
banner.textContent = '';
|
||||
var counts = new Map();
|
||||
Object.keys(payload.by_key || {}).forEach(function(key){
|
||||
var node = FIELD_NODES[key];
|
||||
payload.by_key[key].forEach(function(text){
|
||||
var box = el('div', 'prob', text);
|
||||
if(node) node.probs.appendChild(box); else banner.appendChild(box);
|
||||
});
|
||||
if(node){
|
||||
counts.set(node.pill, (counts.get(node.pill) || 0)
|
||||
+ payload.by_key[key].length);
|
||||
node.group.open = true;
|
||||
}
|
||||
});
|
||||
counts.forEach(function(n, pill){
|
||||
pill.textContent = n + (n === 1 ? ' problem' : ' problems');
|
||||
pill.style.display = '';
|
||||
});
|
||||
(payload.unanchored || []).forEach(function(text){
|
||||
banner.appendChild(el('div', 'prob', text));
|
||||
});
|
||||
var total = (payload.problems || []).length;
|
||||
var save = document.getElementById('save');
|
||||
save.disabled = total > 0;
|
||||
if(total){
|
||||
note(banner, 'bad', 'This configuration would not start.',
|
||||
'The add-on refuses to run on it and would stop with exit 78. '
|
||||
+ 'Saving is blocked until these are fixed; nothing has been written. '
|
||||
+ 'Each message below is the add-on\\u2019s own words, from the same '
|
||||
+ 'check that runs at startup.');
|
||||
banner.insertBefore(banner.lastChild, banner.firstChild);
|
||||
msg(total + (total === 1 ? ' problem' : ' problems')
|
||||
+ ' - saving is blocked', 'bad');
|
||||
} else {
|
||||
msg('this configuration would start', 'ok');
|
||||
}
|
||||
if(payload.device_note){
|
||||
note(banner, '', 'Two range checks could not run.', payload.device_note);
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ THE DEBOUNCE IS WHAT BOUNDS THE READS THIS PAGE MAKES OF HOME ASSISTANT.
|
||||
// Each validation asks the setpoint device(s) for their own range, and those
|
||||
// are real Core API reads on the same session the control loop uses. Because
|
||||
// every keystroke RESETS the timer, sustained typing produces no requests at
|
||||
// all and one lands 350 ms after the operator stops - which is the property
|
||||
// that matters, not the number.
|
||||
function queue(){
|
||||
msg('checking\\u2026');
|
||||
document.getElementById('save').disabled = true;
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = setTimeout(validate, 350);
|
||||
}
|
||||
|
||||
async function validate(){
|
||||
try{
|
||||
const r = await fetch('config/validate', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({options: VALS})});
|
||||
showProblems(await r.json());
|
||||
}catch(e){
|
||||
msg('cannot reach the add-on: ' + e, 'bad');
|
||||
}
|
||||
}
|
||||
|
||||
function showStaleness(keys, stored){
|
||||
var host = document.getElementById('staleness');
|
||||
host.textContent = '';
|
||||
if(!keys || !keys.length) return;
|
||||
var d = note(host, '', 'Saved, but not running yet.',
|
||||
'These settings are stored and differ from what this add-on is running '
|
||||
+ 'right now. They take effect when it next starts:');
|
||||
var pre = el('div', 'diff', keys.map(function(k){
|
||||
return k + ': running ' + JSON.stringify(stored.running[k])
|
||||
+ ' -> stored ' + JSON.stringify(stored.stored[k]);
|
||||
}).join('\\n'));
|
||||
d.appendChild(pre);
|
||||
}
|
||||
|
||||
async function load(){
|
||||
msg('loading\\u2026');
|
||||
try{
|
||||
const r = await fetch('config/model?lang='
|
||||
+ encodeURIComponent((navigator.language || 'en')
|
||||
.slice(0, 2)),
|
||||
{cache: 'no-store'});
|
||||
const m = await r.json();
|
||||
MODEL = m; STORED = m.options; FP = m.fingerprint;
|
||||
VALS = JSON.parse(JSON.stringify(m.options));
|
||||
render();
|
||||
showStaleness(m.running_differs, {running: m.running, stored: m.options});
|
||||
document.getElementById('restart').disabled = false;
|
||||
await validate();
|
||||
}catch(e){
|
||||
msg('cannot load the configuration: ' + e, 'bad');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(){
|
||||
msg('saving\\u2026');
|
||||
document.getElementById('save').disabled = true;
|
||||
var r, body;
|
||||
try{
|
||||
r = await fetch('config/save', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({options: VALS, fingerprint: FP})});
|
||||
body = await r.json();
|
||||
}catch(e){
|
||||
msg('cannot reach the add-on: ' + e, 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
return;
|
||||
}
|
||||
if(r.ok){
|
||||
FP = body.fingerprint;
|
||||
msg('saved - restart to apply', 'ok');
|
||||
var host = document.getElementById('staleness');
|
||||
host.textContent = '';
|
||||
note(host, 'ok', 'Saved. Not applied yet.',
|
||||
'The add-on is still running the settings it started with. Press '
|
||||
+ '"Restart to apply" when you are ready to lose control of the '
|
||||
+ 'battery for a few seconds.');
|
||||
return;
|
||||
}
|
||||
if(body.kind === 'stale'){
|
||||
var host2 = document.getElementById('staleness');
|
||||
host2.textContent = '';
|
||||
var d = note(host2, 'bad', 'Nothing was saved: these settings changed '
|
||||
+ 'somewhere else while you were editing.',
|
||||
'Somebody - most likely the Supervisor\\u2019s Configuration tab - '
|
||||
+ 'wrote these settings after this page loaded. Your edits are still on '
|
||||
+ 'screen and nothing has been overwritten. Read what changed, then '
|
||||
+ 'press Save again if you still want your version.');
|
||||
d.appendChild(el('div', 'diff', (body.changed || []).map(function(k){
|
||||
return k + ': theirs ' + JSON.stringify(body.stored[k])
|
||||
+ ' -> mine ' + JSON.stringify(VALS[k]);
|
||||
}).join('\\n') || 'no key that this page also edits'));
|
||||
FP = body.fingerprint;
|
||||
msg('not saved - the stored settings had changed', 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
return;
|
||||
}
|
||||
if(body.kind === 'invalid'){
|
||||
showProblems(body);
|
||||
msg('not saved - the configuration would not start', 'bad');
|
||||
return;
|
||||
}
|
||||
msg('not saved: ' + (body.error || r.status), 'bad');
|
||||
document.getElementById('save').disabled = false;
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', save);
|
||||
document.getElementById('reload').addEventListener('click', load);
|
||||
document.getElementById('restart').addEventListener('click', function(){
|
||||
document.getElementById('confirm').showModal();
|
||||
});
|
||||
document.getElementById('cancel').addEventListener('click', function(){
|
||||
document.getElementById('confirm').close();
|
||||
});
|
||||
document.getElementById('go').addEventListener('click', async function(){
|
||||
document.getElementById('confirm').close();
|
||||
msg('restarting\\u2026');
|
||||
try{
|
||||
await fetch('config/restart', {method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({confirm: 'restart'})});
|
||||
msg('restart requested - this page will stop answering for a moment');
|
||||
}catch(e){
|
||||
msg('restart requested - this page will stop answering for a moment');
|
||||
}
|
||||
});
|
||||
load();
|
||||
</script></body></html>
|
||||
"""
|
||||
@@ -0,0 +1,315 @@
|
||||
"""UX-02: the config surface's MODEL - built from what the Supervisor returns.
|
||||
|
||||
WHAT THIS MODULE IS FOR, in one sentence: turn the three things
|
||||
`GET /addons/self/info` already carries - `schema`, `translations` and
|
||||
`options` - into the shape a form renders from, WITHOUT this repository owning
|
||||
a second copy of any of them.
|
||||
|
||||
⚠️ THE RULE THIS FILE EXISTS TO OBEY. There is exactly one description of the
|
||||
option surface (config.yaml's `schema:` block), exactly one set of labels
|
||||
(translations/*.yaml, UX-01) and exactly one set of cross-field rules
|
||||
(app/validate.py). A config UI is the classic place where all three get
|
||||
transcribed a second time - a hand-written field list, a hand-written label, a
|
||||
hand-written "max_w must not exceed the device" in JavaScript - and the second
|
||||
copy is what drifts. So:
|
||||
|
||||
* the FIELDS come from `info["schema"]`, which is Supervisor's own
|
||||
`App.schema_ui` == `UiOptions(...)(config.yaml["schema"])`. Nothing here
|
||||
lists an option key.
|
||||
* the LABELS come from `info["translations"]`, which is Supervisor's own
|
||||
`_read_app_translations()` over translations/*.yaml. Nothing here contains
|
||||
an English or Dutch sentence about an option.
|
||||
* the RULES are not here at all. `problems_by_field()` takes strings the
|
||||
SHIPPED validator produced and decides only WHERE ON THE PAGE to put them.
|
||||
|
||||
The consequence is the acceptance criterion: a rule added to validate.py, an
|
||||
option added to config.yaml, or a label improved in translations/en.yaml all
|
||||
reach the panel with no change to this file, to web.py, or to the page's
|
||||
JavaScript. test_selfconfig.py part 6 proves the first of those by running the
|
||||
panel against a MUTATED COPY of validate.py in a subprocess.
|
||||
|
||||
⚠️ STDLIB ONLY, AND THAT IS LOAD-BEARING. This module is imported by
|
||||
tools/supervisor_selfapi_probe.py, which runs inside the Home Assistant
|
||||
Supervisor release image so that the model can be built from the REAL loader's
|
||||
output rather than from a transcription of it. That image has no paho-mqtt, so
|
||||
an import of anything under `app/` that reaches `mqtt.py` would make the probe
|
||||
unrunnable and the measurement would quietly become a re-reading.
|
||||
|
||||
--- WHAT THE SUPERVISOR CANNOT RENDER, AND WHY THAT IS THE POINT -------------
|
||||
|
||||
Add-on options are a FLAT LIST. UX-01's own header says it: no section-header
|
||||
primitive, no units on an input, no per-option grouping. UX-01 answered that by
|
||||
prefixing every label with `Group · `, which is the best a flat list allows.
|
||||
This module reads that prefix back off and turns it into a real heading, so the
|
||||
grouping the operator already has in words becomes grouping they have on the
|
||||
screen - in whichever language their labels resolved in, because the prefix is
|
||||
translated too (`Limits` / `Grenzen`).
|
||||
|
||||
⚠️ NOTHING IS INVENTED WHEN THE PREFIX IS ABSENT. A key whose label carries no
|
||||
` · ` is NOT quietly filed under the last heading seen - that would put a new
|
||||
option in a section nobody chose and it would look deliberate. It goes into the
|
||||
model's `ungrouped` list, is rendered under a heading that says so, and
|
||||
test_selfconfig.py asserts the list is EMPTY today, so the day config.yaml
|
||||
grows a key UX-01 has not grouped, a check says so instead of the page guessing.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
__all__ = ["resolve_entry", "label_of", "help_of", "split_group", "unit_of",
|
||||
"field_model", "problems_by_field", "changed_keys"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. LABELS - the frontend's own resolution order, transcribed once
|
||||
#
|
||||
# ⚠️ THIS IS A TRANSCRIPTION OF SOMEBODY ELSE'S CODE AND IT IS LABELLED AS ONE.
|
||||
# tools/supervisor_shape_probe.py section F reads the resolution out of the
|
||||
# shipped frontend bundle in the release image this site runs
|
||||
# (`5680.*.js`, frontend 20250925.1):
|
||||
#
|
||||
# computeLabel = (s,_,o) => this._getTranslationEntry(this.hass.language,s,o)?.name
|
||||
# || this._getTranslationEntry("en",s,o)?.name
|
||||
# || s.name
|
||||
#
|
||||
# i.e. THE FALLBACK IS PER KEY, `en` is the second chance and the raw key is the
|
||||
# third. The panel resolves labels the same way ON PURPOSE: two config surfaces
|
||||
# that disagree about what a field is CALLED would be worse than one ugly one.
|
||||
# If that resolution ever changes upstream, this is a copy that can go stale -
|
||||
# it is three lines, it is named here, and the probe prints both renderings side
|
||||
# by side so a divergence is visible rather than argued about.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def resolve_entry(translations, lang, key, path=()):
|
||||
"""Yield `(language, entry)` for `key`, in the per-key `lang -> en` order.
|
||||
|
||||
`translations` is `info["translations"]` verbatim: `{lang: {"configuration":
|
||||
{key: {"name", "description", "fields": {...}}}}}`. `path` names the
|
||||
enclosing repeatable section for a per-entry key, e.g. `("inverters",)`.
|
||||
"""
|
||||
for language in (lang, "en"):
|
||||
node = ((translations or {}).get(language) or {}).get("configuration")
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for step in path:
|
||||
node = ((node.get(step) or {}).get("fields")) or {}
|
||||
entry = node.get(key)
|
||||
if isinstance(entry, dict):
|
||||
yield language, entry
|
||||
|
||||
|
||||
def label_of(translations, lang, key, path=()):
|
||||
"""(label, source-language) with the raw key as the last resort."""
|
||||
for language, entry in resolve_entry(translations, lang, key, path):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if name:
|
||||
return name, language
|
||||
return key, "raw"
|
||||
|
||||
|
||||
def help_of(translations, lang, key, path=()):
|
||||
"""(description, source-language). "" when nothing was translated."""
|
||||
for language, entry in resolve_entry(translations, lang, key, path):
|
||||
text = str(entry.get("description") or "").strip()
|
||||
if text:
|
||||
return text, language
|
||||
return "", "none"
|
||||
|
||||
|
||||
# UX-01's separator, U+00B7 with a space each side. It is not a general
|
||||
# delimiter guess: it is the exact string that file's generator writes, and the
|
||||
# audit in tools/translations_lint.py is what keeps every label carrying one.
|
||||
GROUP_SEP = " · "
|
||||
|
||||
|
||||
def split_group(label):
|
||||
"""`"Limits · Inverter rating (W)"` -> `("Limits", "Inverter rating (W)")`.
|
||||
|
||||
A label with no separator comes back as `("", label)` - see the module
|
||||
docstring on why that is not filed under the previous heading.
|
||||
"""
|
||||
if GROUP_SEP in label:
|
||||
group, _, rest = label.partition(GROUP_SEP)
|
||||
return group.strip(), rest.strip()
|
||||
return "", label
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. UNITS - presentation, derived from the key's own suffix
|
||||
#
|
||||
# ⚠️ THIS IS THE ONE PLACE THIS MODULE PUTS WORDS NEXT TO A FIELD, and it is
|
||||
# deliberately the weakest possible rule: a suffix map, applied to a key name,
|
||||
# affecting nothing but what is printed to the right of an input box. It states
|
||||
# no bound, no default and no relation, so it cannot disagree with config.yaml
|
||||
# or with validate.py about anything - the worst a wrong entry here can do is
|
||||
# print "W" beside a number that is in seconds, which is visible to the first
|
||||
# person who reads the screen.
|
||||
#
|
||||
# It is NOT a schema, and it must never become one: a unit string here that
|
||||
# named a bound - "h (0-23)", say - would be a second copy of a range that
|
||||
# config.yaml already states and the panel already renders from the schema. One
|
||||
# such entry was written and removed; test_selfconfig.py part 5 is what removed
|
||||
# it, by asserting that no unit string contains a digit.
|
||||
#
|
||||
# Anything the map does not know about gets no unit rather than a guessed one.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_UNITS = (
|
||||
("_eur_kwh", "€/kWh"),
|
||||
("_eur", "€"),
|
||||
("_soc_floor", "%"),
|
||||
("_soc_target", "%"),
|
||||
("_soc_ceiling", "%"),
|
||||
("_days", "days"),
|
||||
("_min", "min"),
|
||||
("_hour", "h"),
|
||||
("_horizon_h", "h"),
|
||||
("_cycles", "cycles"),
|
||||
("_phases", "phases"),
|
||||
("_w", "W"),
|
||||
("_s", "s"),
|
||||
)
|
||||
|
||||
|
||||
def unit_of(key):
|
||||
for suffix, unit in _UNITS:
|
||||
if key.endswith(suffix):
|
||||
return unit
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. THE MODEL
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _one_field(node, translations, lang, path=()):
|
||||
"""One entry of Supervisor's `schema_ui` -> one field of the model.
|
||||
|
||||
⚠️ THE TYPE, THE RANGE AND THE ENUM ARE COPIED, NOT DECIDED. `type`,
|
||||
`lengthMin`/`lengthMax` (which is what `UiOptions` calls a NUMERIC range,
|
||||
misleadingly, and measured rather than guessed - see
|
||||
tools/supervisor_selfapi_probe.py section C) and `options` come straight off
|
||||
the node. This function chooses no bound of its own, so a range edited in
|
||||
config.yaml is a range the panel enforces on the very next page load.
|
||||
"""
|
||||
key = str(node.get("name") or "")
|
||||
raw_label, label_lang = label_of(translations, lang, key, path)
|
||||
group, label = split_group(raw_label)
|
||||
text, help_lang = help_of(translations, lang, key, path)
|
||||
field = {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"group": group,
|
||||
"help": text,
|
||||
"label_lang": label_lang,
|
||||
"help_lang": help_lang,
|
||||
"type": str(node.get("type") or "string"),
|
||||
"required": bool(node.get("required")),
|
||||
"multiple": bool(node.get("multiple")),
|
||||
"unit": unit_of(key),
|
||||
}
|
||||
if node.get("lengthMin") is not None:
|
||||
field["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
field["max"] = node["lengthMax"]
|
||||
if node.get("options"):
|
||||
field["options"] = list(node["options"])
|
||||
if node.get("type") == "schema":
|
||||
field["fields"] = [_one_field(sub, translations, lang, path + (key,))
|
||||
for sub in node.get("schema") or ()]
|
||||
return field
|
||||
|
||||
|
||||
def field_model(schema, translations, lang="en"):
|
||||
"""`info["schema"]` + `info["translations"]` -> the groups a page renders.
|
||||
|
||||
Returns `{"groups": [{"name", "fields": [...]}], "ungrouped": [key, ...],
|
||||
"keys": [key, ...]}`.
|
||||
|
||||
⚠️ ORDER IS THE SCHEMA'S, AND SO IS GROUP ORDER. `App.schema_ui` renders
|
||||
config.yaml's `schema:` block in file order (measured in
|
||||
tools/supervisor_shape_probe.py section F), and a group takes its position
|
||||
from the FIRST field that names it. So the panel's section order is
|
||||
config.yaml's section order, and moving a block in that file moves it here -
|
||||
which is the property that stops this model needing an order of its own.
|
||||
"""
|
||||
groups, order, ungrouped, keys = {}, [], [], []
|
||||
for node in schema or ():
|
||||
field = _one_field(node, translations, lang)
|
||||
keys.append(field["key"])
|
||||
name = field["group"]
|
||||
if not name:
|
||||
ungrouped.append(field["key"])
|
||||
if name not in groups:
|
||||
groups[name] = []
|
||||
order.append(name)
|
||||
groups[name].append(field)
|
||||
return {
|
||||
"groups": [{"name": name, "fields": groups[name]} for name in order],
|
||||
"ungrouped": ungrouped,
|
||||
"keys": keys,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. WHERE A REFUSAL GOES ON THE PAGE
|
||||
#
|
||||
# ⚠️ READ THIS BEFORE ADDING ANYTHING TO IT. The entire value of this card is
|
||||
# that validate.py's cross-field sentences reach the operator BEFORE a save.
|
||||
# Those sentences are written by validate.py and are shown BYTE FOR BYTE. This
|
||||
# function decides one thing and one thing only: which field to print each
|
||||
# sentence under. It does not shorten, re-word, classify by severity, or map a
|
||||
# message to a rule number - every one of those would be a second, drifting
|
||||
# opinion about a rule this module is not allowed to hold.
|
||||
#
|
||||
# THE MECHANISM IS NAME OCCURRENCE, and it knows no keys of its own: the caller
|
||||
# passes the key list from the schema. A message that names `max_w` is filed
|
||||
# under `max_w`. That is why validate.py's habit of naming the option it is
|
||||
# refusing - which it already had, for the log - is what makes the anchoring
|
||||
# work, and why nothing here breaks when a rule is added.
|
||||
#
|
||||
# ⚠️ WORD BOUNDARIES, AND THE COLLISION THEY EXIST FOR. `sign_disagree_w` is a
|
||||
# substring of `unit_sign_disagree_w` and of `site_sign_disagree_w` - three
|
||||
# distinct options, two of them SAFETY-30's. A plain `in` test files every
|
||||
# per-unit refusal under the site key as well, which would put a message about
|
||||
# unit-0 next to a field that has nothing to do with it. The boundary class
|
||||
# excludes `_` on purpose so that the longer names do not match the shorter key.
|
||||
#
|
||||
# ⚠️ AND THE HONEST LIMIT, STATED RATHER THAN DISCOVERED: a refusal whose
|
||||
# sentence never spells an option key cannot be anchored, and is shown in the
|
||||
# page's top-level refusal block instead. Nothing is lost - it is the same
|
||||
# sentence, on the same screen, before the same save - it simply is not beside
|
||||
# a box. test_selfconfig.py part 4 MEASURES how many of the shipped rules
|
||||
# anchor and prints the ones that do not, rather than this comment claiming a
|
||||
# coverage figure.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def problems_by_field(problems, keys):
|
||||
"""(by_key, unanchored) for a list of validator sentences.
|
||||
|
||||
A sentence naming several keys is filed under EVERY one of them - a
|
||||
cross-field rule is about all of its fields, and hiding it from one of them
|
||||
would be this card's own defect in miniature.
|
||||
"""
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
|
||||
+ r"(?![A-Za-z0-9_])")) for key in keys]
|
||||
by_key, unanchored = {}, []
|
||||
for problem in problems:
|
||||
hit = False
|
||||
for key, pattern in patterns:
|
||||
if pattern.search(problem):
|
||||
by_key.setdefault(key, []).append(problem)
|
||||
hit = True
|
||||
if not hit:
|
||||
unanchored.append(problem)
|
||||
return by_key, unanchored
|
||||
|
||||
|
||||
def changed_keys(left, right):
|
||||
"""Sorted keys whose values differ between two option blobs.
|
||||
|
||||
Used for one thing: saying WHICH settings are stored but not yet running,
|
||||
and which the other configuration surface moved underneath this one. It
|
||||
compares values as they are, so a list reordered counts as changed - which
|
||||
is correct for `inverters`, where order is the roster order.
|
||||
"""
|
||||
return sorted(k for k in set(left) | set(right)
|
||||
if left.get(k) != right.get(k))
|
||||
@@ -192,8 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
bms_soc_floor=None, capacity_kwh=None, store=None,
|
||||
sign_cfg=None, delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -215,6 +215,20 @@ class Inverter:
|
||||
# the config by name long before that, at startup.
|
||||
self.bms_soc_floor = (None if bms_soc_floor is None
|
||||
else float(bms_soc_floor))
|
||||
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
|
||||
# entry did not state one.
|
||||
#
|
||||
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
|
||||
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
|
||||
# to be defaulted, and one default for two batteries protects the
|
||||
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
|
||||
# A missing capacity has an honest answer that costs nothing: this pack
|
||||
# states no energy headroom, so `packshare.weights()` has NO OPINION
|
||||
# about the split and every unit weighs 1.0, which is exactly the
|
||||
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
|
||||
# that may never be used, and nobody decides that by omitting a key.
|
||||
self.capacity_kwh = (None if capacity_kwh is None
|
||||
else float(capacity_kwh))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
it - see the options-block note on `inverters:`.
|
||||
|
||||
NON-EMPTY each entry describes one inverter and the flat keys describe
|
||||
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
|
||||
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
|
||||
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
@@ -512,13 +528,20 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
max_w=_flat_max_w(opts),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
|
||||
# DECISION. Capacity is read for ONE purpose - weighting a split
|
||||
# between two packs - and at the single-inverter shape there is no
|
||||
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
|
||||
# is arithmetic nobody reads. A flat key would be an option an
|
||||
# operator can set, that the add-on reads, and that changes nothing,
|
||||
# which is the worst of the three.
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
@@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
# OPT-09, and NOT inherited either, for the reason above it: a
|
||||
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
|
||||
# though it were a 20.48 kWh one, which is the split this card
|
||||
# exists to stop being wrong.
|
||||
capacity_kwh=_entry_capacity_kwh(entry),
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
@@ -588,6 +616,60 @@ def _entry_bms_floor(entry: dict):
|
||||
return None
|
||||
|
||||
|
||||
def _flat_max_w(opts: dict) -> float:
|
||||
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
|
||||
|
||||
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
|
||||
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
|
||||
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
|
||||
is content - while the bare `float("")` here raised ValueError from inside
|
||||
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
|
||||
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
|
||||
and for the most prominent limit field's most likely transient state.
|
||||
|
||||
That inverted this module's own docstring rule - "keep the two in step: a
|
||||
shape this tolerates that rule 9 does not name is a shape that runs" - in
|
||||
the other direction: a shape rule 9 TOLERATED could not be composed at all.
|
||||
The roster path never had the bug, because `_entry_max_w` below already
|
||||
guards; only the flat path did.
|
||||
|
||||
Empty or absent is the default, exactly as `_number()` reads it.
|
||||
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
|
||||
it by name at line 790, with a sentence, before a control loop exists.
|
||||
"""
|
||||
raw = opts.get("max_w", 2000)
|
||||
if raw is None or raw == "":
|
||||
return 2000.0
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _entry_capacity_kwh(entry: dict):
|
||||
"""One entry's OWN usable capacity in kWh, or None when it stated none.
|
||||
|
||||
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
|
||||
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
|
||||
name, but a zero CAPACITY is a pack with no energy headroom in either
|
||||
direction, i.e. a pack the weighting would silently stop preferring. "Not
|
||||
stated" and "empty" must not be the same answer - `packshare.weights()`
|
||||
turns the first into "no opinion at all" and the second into a weight of
|
||||
zero, and those are different splits.
|
||||
|
||||
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
|
||||
startup with a message; until it does, withholding is the direction that
|
||||
cannot invent a preference.
|
||||
"""
|
||||
raw = entry.get("capacity_kwh")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return val if val > 0.0 else None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ from .maintenance import (
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
@@ -145,6 +146,23 @@ def load_options() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def pack_energy_of(inv):
|
||||
"""OPT-09. ONE roster entry as the view `packshare` consumes.
|
||||
|
||||
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
|
||||
needs the whole roster (for the weights and the trade sizing) and
|
||||
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
|
||||
claim). Written out twice, the two would drift - and the direction they
|
||||
would drift in is a unit whose envelope is computed from a different set of
|
||||
numbers than its weight, which is precisely the class of defect DEV-02 spent
|
||||
a card removing. It is a module function rather than a method on `Inverter`
|
||||
so that `inverter.py` does not have to import `packshare`.
|
||||
"""
|
||||
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db"):
|
||||
@@ -628,6 +646,12 @@ class Controller:
|
||||
# edge-triggered. Starts as the empty tuple - nothing has been dropped
|
||||
# yet and nothing has been said about it.
|
||||
self._dropped_last = ()
|
||||
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
|
||||
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
|
||||
# every watt stays that way for as long as the pack is empty, and a line
|
||||
# a second would flush the 40-row action log of the context explaining
|
||||
# it. 0 is "every watt was placed", which is the state at boot.
|
||||
self._unmet_last = 0
|
||||
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
|
||||
# and `batt` were per-device scalars and now live on each `Inverter`;
|
||||
# `grid` is P1, one meter, one connection, and belongs here.
|
||||
@@ -1015,6 +1039,55 @@ class Controller:
|
||||
"""
|
||||
return float(sum(inv.max_w for inv in self.inverters))
|
||||
|
||||
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
|
||||
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can
|
||||
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
|
||||
handed `site_max_w` on both sides, so on a roster of two the trade was
|
||||
sized at the sum of the ratings whatever either pack's state of charge
|
||||
was - the site would buy at 9600 W with only one pack able to absorb it,
|
||||
and the watts that could not be placed would come back as `unmet_w` for
|
||||
the site integrator to chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
|
||||
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
|
||||
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
|
||||
`pack` claim - answers "what may this pack be commanded at all", and
|
||||
that one is the pack's own physical cut-out for the reason SAFETY-25
|
||||
gives. Sizing with the physical floor would let the strategy ask for
|
||||
watts its own reserve forbids; bounding the envelope with the trade's
|
||||
reserve would let a trade preference stop the grid-following loop.
|
||||
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
|
||||
carrying the trade is the allocator's answer, and the only per-pack
|
||||
instrument it has for a TRADE reserve is the weights - which are a
|
||||
preference, not a permission (`allocator._fill()` will hand watts to a
|
||||
zero-weighted unit rather than leave them unmet). So a pack between its
|
||||
arbitrage floor and its BMS cut-out can still take part of a sell. That
|
||||
is stated rather than fixed: fixing it needs a per-pack envelope that
|
||||
depends on WHICH claim won the tick, which the arbiter's partition does
|
||||
not express, and is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side. It is not
|
||||
reachable with control running - `inputs_ok` stops the site when any
|
||||
pack's SoC is missing - and withholding is the direction that cannot
|
||||
over-size.
|
||||
|
||||
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
|
||||
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
|
||||
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
|
||||
that outranks the loop.
|
||||
"""
|
||||
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
|
||||
# method is the roster view it is applied to. A transcription of the
|
||||
# arithmetic into a check is one copy plus a future disagreement about
|
||||
# what it means, and the copy that drifts is always the grading one -
|
||||
# so `test_packshare.py` part 9 calls the SAME function this does.
|
||||
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
|
||||
|
||||
@property
|
||||
def site_rating_w(self) -> float:
|
||||
"""The largest magnitude the SITE can actually be commanded, in W.
|
||||
@@ -1132,6 +1205,44 @@ class Controller:
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def pack_energy(self) -> list:
|
||||
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
|
||||
|
||||
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
|
||||
That list answers the FLOOR question - may this pack be spent - and its
|
||||
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
|
||||
capacity and would have to default one. This list answers the SPLIT
|
||||
question - which pack should carry more of the site target - and cannot
|
||||
be asked without one.
|
||||
|
||||
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
|
||||
what `live_units()` does not list and `plan()` ignores a weight for a
|
||||
unit it dropped, so filtering here would only move the same decision to
|
||||
a worse place - and `packshare.weights()` needs to see a pack that did
|
||||
not answer in order to withhold its opinion about the whole roster.
|
||||
"""
|
||||
return [pack_energy_of(inv) for inv in self.inverters]
|
||||
|
||||
def pack_weights(self, target_w):
|
||||
"""OPT-09. The comfort weights for a site target of `target_w`.
|
||||
|
||||
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
|
||||
and that is not indirection for its own sake. An energy-headroom
|
||||
weighting is direction-dependent - room above the floor when the site is
|
||||
discharging, room below full when it is charging - and the two give
|
||||
opposite answers on the same roster. The direction is the SIGN OF THE
|
||||
SITE TARGET, which is stage 2's output, so nothing here can know it
|
||||
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
|
||||
between stage 2 and stage 3, with the number it just chose.
|
||||
|
||||
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
|
||||
allocator refuses to cache an envelope: a pack's SoC moves for reasons
|
||||
that have nothing to do with the allocator, and a weighting held from
|
||||
construction prefers a pack that has since emptied.
|
||||
"""
|
||||
return packshare.weights(self.pack_energy, target_w)
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1930,6 +2041,39 @@ class Controller:
|
||||
# setpoint entity (§11.3), not a bound on the grid connection.
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
|
||||
actuator=self.actuator_of("supervised", inv)))
|
||||
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
|
||||
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
|
||||
# WEIGHT. Weighting an empty pack down does NOT stop it being
|
||||
# commanded: `allocator._fill()` runs a deliberate last-resort pass
|
||||
# over the zero-weight units when the preferred ones saturate, so
|
||||
# that a preference can never manufacture a standing error at the
|
||||
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
|
||||
# absolutely. So a pack at or under its own BMS cut-out gets
|
||||
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
|
||||
# goes to the sibling instead of being asked of a battery that
|
||||
# cannot answer it.
|
||||
#
|
||||
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
|
||||
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
|
||||
# `actuator_for()` routes every source to the SITE at N=1 - with one
|
||||
# inverter the fabricated grid reading and the battery setpoint are
|
||||
# the same number - so this interval would bound THE SITE TARGET
|
||||
# there, not a share. That is a behaviour change on the live
|
||||
# single-inverter install and it is the shape SAFETY-25 decided
|
||||
# against on 2026-09-02 (Option A): ordinary self-consumption is not
|
||||
# given an SoC floor by this card. There is also nothing for it to
|
||||
# do at N=1 - no sibling to move the share to - so the whole of AC 4
|
||||
# ("at N=1 allocation is byte-identical to today") is this `if`.
|
||||
#
|
||||
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
|
||||
# discharging into the BMS cut-out. That is unchanged, it is
|
||||
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
|
||||
if n_units > 1:
|
||||
_pe = packshare.envelope(pack_energy_of(inv))
|
||||
if _pe.binding:
|
||||
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
|
||||
_pe.why,
|
||||
actuator=self.actuator_of("pack", inv)))
|
||||
# The SITE bound, for the site-level consumers below. It is the roster
|
||||
# sum: what this site can deliver, not what one of its inverters can.
|
||||
max_w = self.site_max_w
|
||||
@@ -2202,6 +2346,7 @@ class Controller:
|
||||
self.arb = None
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
self.arb = arbitrage.decide(
|
||||
self.fresh_tariff_plan(now), now,
|
||||
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||
@@ -2214,7 +2359,22 @@ class Controller:
|
||||
soc=self.pack_floor_soc,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
charge_w=max_w, discharge_w=max_w,
|
||||
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
|
||||
# both sides any more: the trade is sized to the packs whose own
|
||||
# state of charge permits that direction, so it can never ask
|
||||
# for more than the roster can take. `trade_power_w()` carries
|
||||
# the argument, including which of the two SoC bounds belongs
|
||||
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
|
||||
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
|
||||
# the one pack that passed the gate is the whole roster.
|
||||
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
|
||||
# because `soc=` below is the EMPTIEST pack, so a sell is
|
||||
# refused outright whenever any pack is at or under the floor -
|
||||
# i.e. whenever this sum could be smaller than the site rating.
|
||||
# It becomes reachable the day that gate becomes per-pack;
|
||||
# `test_packshare.py` part 9 grades it directly rather than
|
||||
# letting an unreachable branch go ungraded.
|
||||
charge_w=charge_w, discharge_w=discharge_w,
|
||||
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||
# SITE, and arbitrage.claim() says so on the object it builds. It
|
||||
# buys and sells at the grid against a day-ahead price: the price is
|
||||
@@ -2419,8 +2579,24 @@ class Controller:
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
self.alloc_tick += 1
|
||||
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
|
||||
# allocator one free parameter and ALLOC-01 built the machinery that
|
||||
# consumes it (`allocator._fill`); this line is the first thing that has
|
||||
# ever passed one. Without it every unit weighed 1.0 and the split was
|
||||
# a fixed 50/50 regardless of what either pack could do.
|
||||
#
|
||||
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
|
||||
# therefore direction-dependent, and the direction is the sign of the
|
||||
# site target - which stage 2 chooses inside `resolve()`. See
|
||||
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
|
||||
# between stage 2 and stage 3.
|
||||
#
|
||||
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
|
||||
# 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(),
|
||||
tick=self.alloc_tick)
|
||||
weights=self.pack_weights, tick=self.alloc_tick)
|
||||
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
|
||||
@@ -2514,6 +2690,25 @@ class Controller:
|
||||
if self._dropped_last and not _dropped:
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split")
|
||||
self._dropped_last = _dropped
|
||||
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
||||
# `unmet_w` is the site target the envelopes could not absorb. It is
|
||||
# NOT a broken allocation - `Allocation`'s own docstring says why
|
||||
# fusing it with `ok` would let one saturated inverter park the whole
|
||||
# site - but it IS the site failing to deliver what the loop asked
|
||||
# for, and before this card nothing anywhere reported it. It is the
|
||||
# state a closed envelope produces when BOTH packs are at their
|
||||
# limits: the ask has nowhere left to go, and an operator watching a
|
||||
# standing grid error needs the reason rather than the symptom.
|
||||
# Edge-triggered on the VALUE, for `_dropped` reason.
|
||||
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
|
||||
if _unmet != self._unmet_last:
|
||||
if _unmet:
|
||||
self.log_event(
|
||||
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
|
||||
f"placed on any inverter - {self.allocation.explain()}")
|
||||
else:
|
||||
self.log_event("ALLOCATOR: the whole site target fits again")
|
||||
self._unmet_last = _unmet
|
||||
if resolution.scope_errors:
|
||||
# A claim reached the arbiter on an actuator its source's scope does
|
||||
# not permit. It is a BUG in the filing site, not a state to survive:
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
|
||||
|
||||
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
|
||||
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
|
||||
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
|
||||
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
|
||||
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
|
||||
what fills that argument, and it fills the other one too - the per-unit
|
||||
interval - because on their own the weights cannot do the job. Read the two
|
||||
sections below in order; the second is the important one.
|
||||
|
||||
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
|
||||
===================================================================
|
||||
`allocator.py`'s docstring states the rule this module is built on:
|
||||
|
||||
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
"please do not use this unit", and it is honoured while any other unit has
|
||||
headroom - but it is NOT a constraint [...] To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`.
|
||||
|
||||
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
|
||||
the zero-weight units when the preferred ones fill up, deliberately, so that a
|
||||
preference can never manufacture a standing error at the meter. So a pack at its
|
||||
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
|
||||
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
|
||||
|
||||
Hence this module produces two things and they answer two different questions:
|
||||
|
||||
envelope(pack) MAY this pack be asked, at all, in this direction?
|
||||
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
|
||||
allocator obeys absolutely (`cap` is never overridden).
|
||||
weights(packs,S) GIVEN that both may, which should carry more of it?
|
||||
-> a comfort choice, in the sense ARCH-01 means: the gain
|
||||
seen at the meter is a CONVEX COMBINATION of the
|
||||
per-unit gains for ANY split, so this cannot destabilise
|
||||
the site whatever it says.
|
||||
|
||||
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
|
||||
EASY TO OVER-READ
|
||||
============================================================================
|
||||
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
|
||||
whatever about whether the site can REGULATE. A unit sitting at its energy limit
|
||||
has g_i = 0 - it answers no watts to any command - and the convex combination
|
||||
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
|
||||
and the site is still stable, while the load it was asked to carry simply does
|
||||
not move. `compute()` sees the resulting standing error, trips its saturation
|
||||
freeze and holds the site target where it is, so the share of a unit that cannot
|
||||
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
|
||||
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
|
||||
Weighting a failing unit down is not a remedy for it. The remedies this
|
||||
add-on has are the ENVELOPE (here, when the reason is the pack's own state
|
||||
of charge - a fact this controller reads) and `live_units()` (when the unit
|
||||
cannot be commanded at all). SAFETY-42 measured what the third case costs:
|
||||
with one unit wedged and equal weights, the site settles 700 W short and
|
||||
never recovers, because the freeze pins the target and the sibling's share
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
|
||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
||||
=======================================================================
|
||||
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
||||
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
||||
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
|
||||
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
|
||||
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||
module exists to exploit.
|
||||
|
||||
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
|
||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
|
||||
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
|
||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
|
||||
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
||||
hands the wedged unit a big share again and the alarm returns.
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
|
||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
||||
is a disclosed residual, not a fixed thing. Closing it means making
|
||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
||||
and a design change, filed as its own card and deliberately NOT done here.
|
||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
||||
and a green check that says otherwise would be worse than the gap.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
|
||||
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
|
||||
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
|
||||
power rating hands the trade to A; a split by energy headroom hands it to B. The
|
||||
question the split is answering is "which pack can sustain its share for the
|
||||
hour", so it is the energy one, and the ratings are already applied - as the
|
||||
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
|
||||
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
|
||||
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
|
||||
every two-unit result this card reports is TWO COPIES OF A driven with different
|
||||
numbers, which is a statement about the arithmetic and not about the hardware.
|
||||
|
||||
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
|
||||
THEM WRONG
|
||||
==========================================================================
|
||||
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
|
||||
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
|
||||
`account()` had to make and it is made for the same reason, one direction
|
||||
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
|
||||
self-consumption discharges straight past it, and closing a unit's envelope at
|
||||
it would silently turn an EMS preference into a hard stop on the grid-following
|
||||
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
|
||||
not this card's to build. `bms_soc_floor` is a property of the battery in the
|
||||
cupboard: below it the pack's own BMS stops, so a discharge command sent there
|
||||
is a command that cannot be executed by anything.
|
||||
|
||||
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
|
||||
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
|
||||
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
|
||||
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
|
||||
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
|
||||
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
|
||||
exactly the mistake the paragraph above refuses, in the charge direction. So the
|
||||
envelope closes only where the ask is physically impossible - a pack reporting
|
||||
100 % being told to take more - and everything between is left to the WEIGHTS,
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
|
||||
=====================================================
|
||||
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
|
||||
`bms_soc_floor` required, because the two absences mean different things: a
|
||||
missing cut-out would have to be DEFAULTED and a default protects the shallower
|
||||
pack with the deeper pack's number, whereas a missing capacity can be answered
|
||||
with "then this module has no opinion about the split" - which is exactly
|
||||
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
|
||||
EVERY pack in the roster states both a capacity and a state of charge: a
|
||||
half-known roster would weight the packs that answered against a sibling
|
||||
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import inf, isfinite
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The only charge ceiling that is a property of a PACK rather than of a policy.
|
||||
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
|
||||
# must not become a P_SAFETY bound on the grid-following loop.
|
||||
FULL_PCT = 100.0
|
||||
|
||||
# The claim source name main.py files these intervals under.
|
||||
#
|
||||
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
|
||||
# constant is a NAME FOR THE CHECKS rather than something to import at the
|
||||
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
|
||||
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
|
||||
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
|
||||
# they would have to resolve. So the two spellings are tied by a check instead -
|
||||
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
|
||||
# which is the one direction that cannot make the scanners vacuous.
|
||||
SOURCE = "pack"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnergy:
|
||||
"""ONE pack's four numbers for the two questions this module answers.
|
||||
|
||||
`name` the roster name. An envelope that does not say WHICH pack it
|
||||
closed sends an operator to the wrong cupboard - the defect
|
||||
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
|
||||
both written for.
|
||||
`soc` THIS pack's measured state of charge, in percent. None when
|
||||
its sensor did not answer, and ⚠️ None IS NOT A FULL
|
||||
BATTERY - `discharge_permitted()` says why at length.
|
||||
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
|
||||
BMS cuts out. Required of every roster entry since
|
||||
SAFETY-32, so at a declared roster this is never None.
|
||||
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
|
||||
state one, which means "no opinion" and never 0 - a zero
|
||||
capacity is a pack that may never be used, which is a
|
||||
decision nobody made by omitting a key.
|
||||
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
|
||||
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
|
||||
is that the split is by ENERGY and the rating is applied
|
||||
separately, per unit, as the `supervised` and `device`
|
||||
claims. It is here for `trade_power_w()` alone, which
|
||||
answers a different question: how many watts the packs that
|
||||
are ALLOWED to take a trade can take between them.
|
||||
|
||||
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
|
||||
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
|
||||
question needs and is consumed by `pack_permissions()` and
|
||||
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
|
||||
for money. This one carries a fourth number that is meaningless to those
|
||||
reductions and would have to be defaulted into them. Two small types with
|
||||
one purpose each beat one type with a field half its callers must ignore.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
capacity_kwh: float | None
|
||||
max_w: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnvelope:
|
||||
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
|
||||
|
||||
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
|
||||
caller asks whether there is anything to file. A claim filed for every pack
|
||||
on every cycle regardless would put an open limit into the arbiter's
|
||||
considered list on every tick, which costs nothing arithmetically and buries
|
||||
the one cycle where it means something.
|
||||
"""
|
||||
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
why: str = ""
|
||||
|
||||
@property
|
||||
def binding(self) -> bool:
|
||||
return self.lo != -inf or self.hi != inf
|
||||
|
||||
|
||||
def usable_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
|
||||
|
||||
None when it cannot be computed - no capacity, no reading, or no floor - and
|
||||
never 0.0 for those, because "no opinion" and "nothing left" are the two
|
||||
answers a weighting must not confuse: the first weighs 1.0 and the second
|
||||
weighs nothing at all.
|
||||
|
||||
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
|
||||
-0.4 kWh of usable energy by arithmetic and at zero in fact.
|
||||
"""
|
||||
if (pack.capacity_kwh is None or pack.soc is None
|
||||
or pack.bms_floor is None):
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
|
||||
|
||||
|
||||
def spare_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still ACCEPT before it is full.
|
||||
|
||||
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
|
||||
argument, and it is the same one that keeps `peak_soc_floor` out of
|
||||
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
|
||||
|
||||
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
|
||||
constant-voltage taper, in which a pack legitimately accepts far less than
|
||||
it is told for tens of minutes - there is no configured ceiling in this
|
||||
add-on that could describe that, which is `delivery.py`'s stated reason for
|
||||
being armed on discharge shares only.
|
||||
"""
|
||||
if pack.capacity_kwh is None or pack.soc is None:
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (FULL_PCT - float(pack.soc)) / 100.0)
|
||||
|
||||
|
||||
def envelope(pack) -> PackEnvelope:
|
||||
"""The interval this pack may be commanded in, from its state of charge.
|
||||
|
||||
Positive is DISCHARGE throughout this add-on, so:
|
||||
|
||||
* a pack at or below its own cut-out gets `hi = 0` - it may still be
|
||||
charged, it may not be asked to discharge;
|
||||
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
|
||||
be asked to charge.
|
||||
|
||||
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
|
||||
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
|
||||
function is already the one place this question is answered - for the peak
|
||||
guard's own verdict and for `delivery.account()`'s discriminator - and a
|
||||
safety predicate transcribed into a third copy is two future disagreements
|
||||
about what it means. What is NOT reused is its wording, which is peak-guard
|
||||
flavoured; the floor handed in is the pack's own cut-out, exactly as
|
||||
`delivery.account()` hands it.
|
||||
|
||||
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
|
||||
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
|
||||
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
|
||||
money", where an unread SoC must not authorise the spend and a full battery
|
||||
is exactly the value that would disarm the floor. The question here is
|
||||
different: whether to take a unit's share away from it and hand it to a
|
||||
sibling. An absent measurement is not evidence that a battery is flat, and
|
||||
closing on it would park a healthy unit's discharge on every tick for as
|
||||
long as its sensor was missing - moving load onto its sibling on the
|
||||
strength of a reading nobody took.
|
||||
|
||||
Both absences already have a stronger answer arriving first, which is why
|
||||
this one costs nothing: a missing SoC stops the WHOLE SITE through
|
||||
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
|
||||
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
|
||||
config.yaml's own schema, which requires the key of every entry. So the
|
||||
conjunction below is "positive evidence only": both numbers present, and the
|
||||
predicate says the pack is at or under its own cut-out.
|
||||
|
||||
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
|
||||
place the comparison is written - what this module decides for itself is the
|
||||
two None cases, which it decides differently and says why. The BOUNDARY
|
||||
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
|
||||
and is graded against it over the whole percent range in test_packshare.py
|
||||
part 2, so a future change to that boundary moves both.
|
||||
|
||||
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
|
||||
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
|
||||
the arbiter's rule applies it to the §12 recalibration drain (a SET at
|
||||
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
|
||||
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
|
||||
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
|
||||
interval. On a config that sets the drain target BELOW the cut-out
|
||||
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
|
||||
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
|
||||
what ALREADY happens without this card, because the pack's own BMS refuses
|
||||
the command whatever the allocator sends. The failure is the config's, the
|
||||
remedy is the same, and this envelope does not make it worse; it only makes
|
||||
the refusal visible one layer earlier, in the allocation record.
|
||||
|
||||
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
|
||||
It would take a pack reporting at least 100 % while sitting at or under its
|
||||
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
|
||||
the flat key and every roster entry - so the two conditions cannot both hold.
|
||||
If they somehow did, the result is `[0, 0]`, which is an interval containing
|
||||
exactly one integer rather than a contradiction, and the allocator commands
|
||||
that unit 0 W. Nothing is special-cased for it here: inventing a second
|
||||
opinion about an unreachable state is how unreachable states get opinions
|
||||
that are wrong.
|
||||
"""
|
||||
hi, lo, why = inf, -inf, ""
|
||||
if (pack.soc is not None and pack.bms_floor is not None
|
||||
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
|
||||
hi = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
|
||||
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
|
||||
f"discharge")
|
||||
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
|
||||
lo = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
|
||||
f"charge - it is not asked to")
|
||||
return PackEnvelope(lo=lo, hi=hi, why=why)
|
||||
|
||||
|
||||
def weights(packs, target_w) -> dict | None:
|
||||
"""Per-unit comfort weights for THIS tick's site target, or None for none.
|
||||
|
||||
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
|
||||
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
|
||||
discharging, room below full when it is charging. `allocator._fill()` uses
|
||||
only the RATIOS, so the unit is free and kWh is the honest one to leave it
|
||||
in: it is what a reader of the action log can check against the datasheet.
|
||||
|
||||
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
|
||||
two directions give OPPOSITE answers on the same roster - A at 20 % of
|
||||
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
|
||||
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
|
||||
between them, it is wrong in one of the two on every tick. The site target
|
||||
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
|
||||
honest moment to ask this is between stage 2 and stage 3. That is why
|
||||
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
|
||||
and why main.py hands it a method rather than a dict.
|
||||
|
||||
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
|
||||
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
|
||||
`need = remainder - placed`, which differs from `sign(target_w)` only when a
|
||||
PIN or an envelope that excludes zero has already moved the base - a
|
||||
maintenance drain, a failsafe park. On those ticks the weighting is computed
|
||||
for the other direction and is therefore merely a worse preference; it
|
||||
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
|
||||
is integer arithmetic in the allocator. Chasing the exact direction would
|
||||
mean handing this function into `plan()` and calling it from inside the
|
||||
fill, which would make the allocator's purity depend on a caller's callback.
|
||||
|
||||
None means "no opinion, weigh everything 1.0", and there are three ways to
|
||||
get it, all of which are today's behaviour rather than a new one:
|
||||
|
||||
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
|
||||
would be arithmetic nobody reads;
|
||||
* any pack that cannot state its headroom - see the module docstring on
|
||||
why a half-known roster is worse than an unweighted one;
|
||||
* every pack at zero headroom in this direction. All-zero weights would
|
||||
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
|
||||
come out equal anyway and every `UnitPlan.why` reads "last resort: the
|
||||
preferred units were full" - a true sentence about the wrong thing.
|
||||
"""
|
||||
packs = list(packs)
|
||||
if len(packs) < 2:
|
||||
return None
|
||||
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
|
||||
out = {}
|
||||
for p in packs:
|
||||
kwh = room(p)
|
||||
if kwh is None or not isfinite(kwh):
|
||||
return None
|
||||
out[p.name] = float(kwh)
|
||||
if not any(v > 0.0 for v in out.values()):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
|
||||
"""`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can push
|
||||
AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed
|
||||
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
|
||||
the ratings whatever either pack's state of charge was - and the watts that
|
||||
could not be placed would come back as `unmet_w` for the site integrator to
|
||||
chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A
|
||||
TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask
|
||||
for"; `envelope()` above answers "what may this pack be commanded at all",
|
||||
and that one is the pack's own physical cut-out for SAFETY-25's reason.
|
||||
Sizing with the physical floor would let the strategy ask for watts its own
|
||||
reserve forbids; bounding the envelope with the trade's reserve would let a
|
||||
trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade
|
||||
is the allocator's answer, and the only per-pack instrument it has for a
|
||||
TRADE reserve is the weights - which are a preference, not a permission. So
|
||||
a pack between its arbitrage floor and its BMS cut-out can still take part
|
||||
of a sell. That is stated rather than fixed: fixing it needs an envelope
|
||||
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
|
||||
not express, and it is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side: withholding is the
|
||||
direction that cannot over-size, and `inputs_ok` has stopped the site by
|
||||
then anyway.
|
||||
|
||||
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
|
||||
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
|
||||
that trades today, because the sell gate refuses on the emptiest pack and
|
||||
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
|
||||
refuses a 0 W sizing by name anyway, so that is structural rather than a
|
||||
coincidence of the gates.
|
||||
"""
|
||||
return (float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) < ceiling_pct)),
|
||||
float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) > floor_pct)))
|
||||
@@ -0,0 +1,401 @@
|
||||
"""UX-02: reading and writing THIS add-on's own options, through the Supervisor.
|
||||
|
||||
WHAT THIS IS. An add-on may read and write its own options. That is not a
|
||||
guess: `supervisor/api/middleware/security.py` in
|
||||
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` - the version this
|
||||
site runs - matches an installed app's own token against
|
||||
|
||||
api_bypass = ^(?:|/addons/self/(?!security|update)[^/]+
|
||||
|/addons/self/options/config|/info|/services.*
|
||||
|/discovery.*|/auth)$
|
||||
|
||||
BEFORE it looks at `access_hassio_api` or at any role, so `GET
|
||||
/addons/self/info`, `POST /addons/self/options` and `POST /addons/self/restart`
|
||||
need no `hassio_role` at all. tools/supervisor_selfapi_probe.py RUNS those
|
||||
compiled patterns against these exact paths rather than re-reading the regex,
|
||||
because a bypass list is precisely the kind of thing that is read optimistically.
|
||||
|
||||
⚠️ AND THE PROBE FOUND ONE THE CARD DID NOT PREDICT, WHICH IS WHY IT WAS RUN.
|
||||
`/addons/self/options/validate` - Supervisor's OWN options validator - has TWO
|
||||
path segments after `self`, so `[^/]+` does not match it and it is not in the
|
||||
list of literals either. An add-on cannot call it. That is not a problem for
|
||||
this card, because the Supervisor's validator only knows the schema (types and
|
||||
ranges) and this panel's whole reason to exist is the CROSS-FIELD rules it
|
||||
structurally cannot express - but it does mean there is exactly one validator
|
||||
reachable from here, `app/validate.py`, which is the one we want.
|
||||
|
||||
--- THE THREE THINGS THIS MODULE REFUSES TO DO ------------------------------
|
||||
|
||||
1. ⚠️ IT NEVER WRITES A BLOB THE SHIPPED VALIDATOR REFUSED. Not "warns and
|
||||
writes"; does not write. The reason is specific to an INGRESS panel and it
|
||||
is the failure mode that makes this card dangerous: an invalid blob plus a
|
||||
restart is exit 78, and an add-on that is not running is not serving the
|
||||
panel either. The operator would be left with no way back except the
|
||||
Supervisor UI - the surface this card exists because it is hard to read.
|
||||
So the refusal happens here, before the POST, and the edits stay on screen.
|
||||
|
||||
2. ⚠️ IT NEVER OVERWRITES A BLOB THAT MOVED UNDERNEATH IT. The Supervisor's
|
||||
Configuration tab CANNOT be removed (see DECISIONS/UX-02-two-config-
|
||||
surfaces.md), so there are two editors of one blob, and `POST
|
||||
/addons/self/options` REPLACES the stored user options - measured, not
|
||||
assumed: `App.options`'s setter is `self.persist[ATTR_OPTIONS] = deepcopy(
|
||||
value)`, and the effective options are `deepmerge(config defaults, persist)`.
|
||||
A key omitted from the POST therefore falls back to config.yaml's DEFAULT
|
||||
rather than to its previous value.
|
||||
|
||||
This is the same shape of hazard as two writers on one actuator, and it gets
|
||||
the same discipline. `Controller.write_refusal()` (SAFETY-28) says: A NUMBER
|
||||
REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR PLACED IT, ON THIS TICK, FOR
|
||||
THIS UNIT. Here: A BLOB REACHES THE SUPERVISOR ONLY IF IT WAS DERIVED FROM
|
||||
THE BLOB THAT IS STORED RIGHT NOW. The read hands out a `fingerprint()`, the
|
||||
save hands it back, and the save re-reads and compares before writing. A
|
||||
change made in the other tab in the meantime is a REFUSAL with the changed
|
||||
keys named, not a silent clobber.
|
||||
|
||||
⚠️ THE COMPARISON IS RE-READ IMMEDIATELY BEFORE THE WRITE, not taken from
|
||||
the page load, so the window in which the other surface can slip in is one
|
||||
HTTP round trip rather than however long the operator spent typing.
|
||||
|
||||
3. ⚠️ IT NEVER RESTARTS AS A SIDE EFFECT OF SAVING. Applying options costs a
|
||||
restart, and `Controller.shutdown()` commands 0 W on every inverter on the
|
||||
way out (main.py, "shutting down - commanding 0 W on every inverter"). That
|
||||
is a real, if brief, loss of control of the battery, so it is its own
|
||||
deliberate action with its own confirmation - `restart()` below requires an
|
||||
explicit token in the request body, so no stray POST can spend it.
|
||||
|
||||
--- WHAT THIS MODULE MAY NOT GROW INTO --------------------------------------
|
||||
|
||||
⚠️ NOTHING HERE COMMANDS AN INVERTER, CLEARS A LATCH, OR REACHES A SAFETY
|
||||
MECHANISM. The panel's configuration half talks to the Supervisor about a JSON
|
||||
blob and to `validate.py` about whether that blob is startable; the only
|
||||
actuator-shaped verb in the file is `restart()`, which restarts THIS ADD-ON and
|
||||
whose 0 W is the existing, documented wind-down. Adding "and also set the
|
||||
setpoint while we are here" would put a second writer on the actuator through
|
||||
the config surface, which is the non-goal this add-on is built around.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
|
||||
_LOG = logging.getLogger("goodwe.selfconfig")
|
||||
|
||||
SUPERVISOR_API = "http://supervisor"
|
||||
SELF_INFO = f"{SUPERVISOR_API}/addons/self/info"
|
||||
SELF_OPTIONS = f"{SUPERVISOR_API}/addons/self/options"
|
||||
SELF_RESTART = f"{SUPERVISOR_API}/addons/self/restart"
|
||||
|
||||
# The word the page has to send with a restart request. It is not a password -
|
||||
# ingress means Home Assistant has already authenticated the caller - it is an
|
||||
# INTENT token, so that a reload, a retry or a stray fetch cannot spend a
|
||||
# control interruption on the operator's behalf.
|
||||
RESTART_CONFIRM = "restart"
|
||||
|
||||
# ⚠️ LONGER THAN hass.py's 10 s, AND FOR THE OPPOSITE REASON. `hass.py` would
|
||||
# rather abandon a write and re-issue it on the next tick than hold the control
|
||||
# loop. Nothing here is on the control loop - these calls are made by an
|
||||
# operator standing at a screen - and a Supervisor busy with a backup can take
|
||||
# a while to answer. Abandoning a `POST /addons/self/options` early would leave
|
||||
# the panel unsure whether the blob was stored, which is the one state this
|
||||
# module must never be in.
|
||||
TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
class SupervisorUnavailable(Exception):
|
||||
"""The Supervisor could not be reached, or answered with an error."""
|
||||
|
||||
|
||||
class NotConfirmed(Exception):
|
||||
"""A control interruption was asked for without the intent token.
|
||||
|
||||
Its own exception rather than a `SaveRefused`, because it is not a refusal
|
||||
ABOUT a configuration - nothing was validated and nothing was compared. It
|
||||
means the request did not say what it was asking for.
|
||||
"""
|
||||
|
||||
|
||||
class SaveRefused(Exception):
|
||||
"""The save did not happen, and NOTHING WAS WRITTEN.
|
||||
|
||||
`kind` is `"invalid"` (the shipped validator refused the blob) or `"stale"`
|
||||
(the stored blob changed underneath). `problems` carries the validator's
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
def canonical(options) -> str:
|
||||
"""The one textual form of an options blob, for identity only.
|
||||
|
||||
Sorted keys, no insignificant whitespace, `ensure_ascii=False` so an entity
|
||||
id with a non-ASCII character is one character rather than an escape - the
|
||||
string is hashed, never displayed, so the only property that matters is
|
||||
that equal blobs produce equal text and different blobs do not.
|
||||
"""
|
||||
return json.dumps(options, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def fingerprint(options) -> str:
|
||||
"""The compare-and-swap token for one options blob.
|
||||
|
||||
⚠️ A DIGEST, NOT A VERSION NUMBER, because there is no version to read. The
|
||||
Supervisor exposes no revision or ETag for an add-on's options, so the only
|
||||
thing the panel can hold onto between reading and writing is the CONTENT it
|
||||
read. That is enough for what this is for: the question is not "how many
|
||||
times has this changed" but "is what I am about to overwrite still what I
|
||||
was shown". Identical content is by definition safe to overwrite.
|
||||
"""
|
||||
return hashlib.sha256(canonical(options).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SelfConfig:
|
||||
"""The add-on's own options, read and written through the Supervisor."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
||||
self.session = session
|
||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
async def _call(self, method, url, payload=None):
|
||||
try:
|
||||
async with self.session.request(
|
||||
method, url, headers=self._headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=TIMEOUT_S),
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
# The Supervisor's own message is carried through unedited:
|
||||
# `{"result": "error", "message": "..."}`. A rewritten
|
||||
# error is a second opinion about somebody else's failure.
|
||||
try:
|
||||
detail = (json.loads(text) or {}).get("message") or text
|
||||
except ValueError:
|
||||
detail = text
|
||||
raise SupervisorUnavailable(
|
||||
f"{method} {url} -> HTTP {resp.status}: "
|
||||
f"{str(detail)[:400]}")
|
||||
try:
|
||||
body = json.loads(text) if text else {}
|
||||
except ValueError as err:
|
||||
raise SupervisorUnavailable(
|
||||
f"{method} {url} -> unreadable answer: {err}") from None
|
||||
return body.get("data", body)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
raise SupervisorUnavailable(f"{method} {url} -> {err}") from None
|
||||
|
||||
async def info(self) -> dict:
|
||||
"""`GET /addons/self/info`.
|
||||
|
||||
⚠️ THREE OF THIS PANEL'S FOUR INGREDIENTS COME OUT OF THIS ONE CALL, and
|
||||
that is the reason the panel owns no copy of any of them. Measured on
|
||||
2026.08.0 (`supervisor/api/apps.py:info_data`):
|
||||
|
||||
`options` - `App.options`, i.e. deepmerge(config.yaml defaults,
|
||||
the stored user options). Every key is present, which
|
||||
is what makes a whole-blob read-modify-write safe.
|
||||
`schema` - `App.schema_ui`, i.e. `UiOptions(...)(config.yaml
|
||||
["schema"])`. The field list, types, ranges and
|
||||
enums, in config.yaml's own order.
|
||||
`translations` - `App.translations`, i.e. Supervisor's own
|
||||
`_read_app_translations()` over translations/*.yaml,
|
||||
which is UX-01's work reaching a second surface for
|
||||
free.
|
||||
|
||||
The fourth ingredient is `app/validate.py`, and it is not fetched.
|
||||
"""
|
||||
return await self._call("GET", SELF_INFO)
|
||||
|
||||
async def write_options(self, options: dict) -> None:
|
||||
"""`POST /addons/self/options` - REPLACES the stored user options.
|
||||
|
||||
⚠️ CALL THIS ONLY THROUGH `save()`. On its own it is the unguarded
|
||||
write: no validation, no compare-and-swap. It is a separate method so
|
||||
that the gate above it can be read - and tested - as a gate.
|
||||
"""
|
||||
await self._call("POST", SELF_OPTIONS, {"options": options})
|
||||
|
||||
async def restart(self, confirm: str) -> None:
|
||||
"""`POST /addons/self/restart`. Costs a control interruption.
|
||||
|
||||
The add-on's own wind-down runs first: `Controller.shutdown()` commands
|
||||
0 W on every inverter before the process exits. So this is not an
|
||||
abrupt loss of control, it is a deliberate one - but it IS one, and the
|
||||
page says so before the operator presses it.
|
||||
"""
|
||||
if confirm != RESTART_CONFIRM:
|
||||
raise NotConfirmed(
|
||||
"a restart interrupts control and must be confirmed")
|
||||
_LOG.warning("operator asked for a restart from the ingress panel - "
|
||||
"the wind-down commands 0 W on every inverter first")
|
||||
await self._call("POST", SELF_RESTART)
|
||||
|
||||
|
||||
async def problems_for(opts: dict, hass=None) -> list:
|
||||
"""Every reason the SHIPPED validator would refuse to start on `opts`.
|
||||
|
||||
⚠️ THIS IS THE STARTUP GATE'S OWN COMPOSITION, NOT A SUBSET OF IT. The
|
||||
gate runs in TWO places in main.py and this is the union of both, which is
|
||||
what an operator meets:
|
||||
|
||||
* `amain()` calls `validate_startup_config(opts, dev_min, dev_max)`
|
||||
UNCONDITIONALLY, before anything starts - so this does too, with the
|
||||
flat `setpoint_entity`'s device range when that entity was readable and
|
||||
None when it was not;
|
||||
* `run_control()` adds `validate_unit_ranges(roster, skip_entity=flat)`
|
||||
once every unit's own ESPHome has answered - so this does too, with the
|
||||
same `skip_entity`, so one inverter is not reported twice under two
|
||||
wordings.
|
||||
|
||||
A panel that ran only half the gate would go green on a config that then
|
||||
exits 78 - which is worse than no panel, because the operator would have
|
||||
been told it was fine.
|
||||
|
||||
⚠️ AND THE DEVICE RANGES ARE READ, NOT INVENTED. `hass.limits()` is a READ
|
||||
of a Home Assistant state; nothing here writes anything to any device. When
|
||||
a device does not answer - ESPHome asleep, an entity that does not exist
|
||||
yet, or `hass=None` in a check - the range is None and the range rules are
|
||||
SKIPPED, exactly as they are at startup. That is the honest behaviour and it
|
||||
is also the conservative one: the panel then shows fewer refusals than the
|
||||
startup gate might, never more, so it cannot bless a config the gate refuses
|
||||
on a rating it could not see. It is stated on the page rather than hidden.
|
||||
|
||||
`build_roster(opts, None)` builds the roster with NO Store, so this reads
|
||||
no latch and writes no state; it exists to give `validate_unit_ranges()`
|
||||
the same objects `main.py` gives it, rather than a second transcription of
|
||||
what counts as a roster entry.
|
||||
"""
|
||||
problems, _unread = await problems_and_ranges(opts, hass)
|
||||
return problems
|
||||
|
||||
|
||||
async def problems_and_ranges(opts: dict, hass=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
|
||||
OPERATOR CANNOT SEE. Two rules compare a configured ceiling against the
|
||||
device's own rating, and when the device is silent they are skipped rather
|
||||
than guessed at - so an unread range means the panel is showing FEWER
|
||||
refusals than the startup gate might, and it has to say so, naming the
|
||||
entity that did not answer rather than hinting at one.
|
||||
|
||||
It is a separate function from `problems_for()` because the answer is about
|
||||
the CONFIGURATION UNDER TEST, not about the running roster: an operator who
|
||||
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.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
|
||||
# ⚠️ THE ROSTER IS BUILT BEFORE THE VALIDATOR RUNS, AND IT PARSES NUMBERS.
|
||||
# `build_roster` reaches `inverter.py`'s `float(opts.get("max_w", 2000))`,
|
||||
# which raises on a value the validator has a SENTENCE for. Cleared the
|
||||
# `max_w` box and the panel answered HTTP 500 - not JSON, so the page said
|
||||
# "cannot reach the add-on", which was untrue - while `validate.py`'s
|
||||
# "max_w ... is not a number" was unreachable through the panel and reachable
|
||||
# at startup, where `main.py` validates before it composes. That made this
|
||||
# surface WEAKER than the startup gate for one field, which is the opposite
|
||||
# of what this module claims below.
|
||||
#
|
||||
# A malformed scalar therefore falls back to validating WITHOUT device
|
||||
# bounds. Those bounds come from the roster, and there is no roster; the
|
||||
# validator's own complaint about the unparseable value is the answer the
|
||||
# operator needs, and it is strictly more informative than a 500.
|
||||
try:
|
||||
units = build_roster(opts, None)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# ⚠️ AND IT MUST NOT RETURN AN EMPTY LIST. An empty list means "no
|
||||
# problems", and the page enables Save on it. If the roster could not be
|
||||
# composed at all and the validator has nothing to say about why, that
|
||||
# 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))
|
||||
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 "
|
||||
f"disagreement between the two and should be reported."]
|
||||
return found, []
|
||||
if hass is not None:
|
||||
async def _limits(unit):
|
||||
if unit.setpoint_entity:
|
||||
unit.dev_min, unit.dev_max, _ = await hass.limits(
|
||||
unit.setpoint_entity)
|
||||
await asyncio.gather(*(_limits(unit) for unit in units))
|
||||
|
||||
dev_min = dev_max = None
|
||||
for unit in units:
|
||||
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 += 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
|
||||
and unit.dev_max is None]
|
||||
return problems, unread
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
with nothing written otherwise.
|
||||
|
||||
THE ORDER IS DELIBERATE AND BOTH HALVES ARE PRE-WRITE GATES:
|
||||
|
||||
1. VALIDATE FIRST. It is local, it costs nothing, and its refusal is
|
||||
about what is on the operator's screen - so it is the answer they can
|
||||
act on without knowing anything about the other surface.
|
||||
2. RE-READ AND COMPARE SECOND, immediately before the POST, so the
|
||||
window in which the Supervisor tab can slip a change past is one round
|
||||
trip rather than the length of an editing session.
|
||||
|
||||
3. Only then write.
|
||||
|
||||
⚠️ `expect` IS REQUIRED AND THERE IS NO "FORCE" DOOR. A caller that has not
|
||||
read the current blob has nothing to compare, and a save that skips the
|
||||
comparison is the clobber this gate exists to prevent. If the operator
|
||||
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.
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
|
||||
"nothing written. First: %s", len(problems), problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
stored = info.get("options") or {}
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
_LOG.warning("panel REFUSED to save: the stored options changed "
|
||||
"underneath this page (keys: %s); nothing written",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
"before they take effect")
|
||||
return {"fingerprint": fingerprint(proposed)}
|
||||
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
||||
callers it does not.
|
||||
|
||||
Five things are refused, and each is a config that would otherwise RUN:
|
||||
Seven things are refused, and each is a config that would otherwise RUN:
|
||||
|
||||
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
||||
can name it; without the rule the roster would be one inverter built
|
||||
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
config. Refused rather than ignored, for §1.5's reason and for this
|
||||
card's: a config that silently becomes a different config is worse than
|
||||
one that refuses to load.
|
||||
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
||||
positive number of kWh. It is read to weight the SPLIT by energy
|
||||
headroom; a zero, a negative or a word is a pack the split would
|
||||
quietly stop preferring while the operator can see a value in the box.
|
||||
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
||||
weighting is a COMPARISON between packs, so a pack measured in kWh
|
||||
against a sibling weighing a dimensionless 1.0 is not one -
|
||||
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
||||
case, which means every key the operator filled in does nothing at all
|
||||
and nothing says so. All or none, and this is clause (e)'s argument
|
||||
restated: a config that silently becomes a different config is worse
|
||||
than one that refuses to load.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
raw = opts.get("inverters")
|
||||
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
||||
"rating in watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
stated, blank = [], []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue # already refused above, by name
|
||||
raw = entry.get("capacity_kwh")
|
||||
nm = str(entry.get("name") or "").strip()
|
||||
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
||||
if raw is None or raw == "":
|
||||
blank.append(who)
|
||||
continue
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
|
||||
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
||||
"read to weight the split of the site target by ENERGY headroom "
|
||||
"rather than by power rating; a zero or an unparseable value is "
|
||||
"a pack the split would quietly stop preferring while the box "
|
||||
"still shows a value. State it in kWh, or leave it empty on "
|
||||
"every entry.")
|
||||
else:
|
||||
stated.append(who)
|
||||
if stated and blank:
|
||||
problems.append(
|
||||
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
||||
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
||||
"split weights one pack's energy headroom against another's, so a "
|
||||
"pack measured in kWh beside a sibling that weighs a dimensionless "
|
||||
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
|
||||
"whole roster and the value you filled in changes nothing. State a "
|
||||
"capacity for every inverter, or for none of them.")
|
||||
|
||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||
if entries and flat_sp:
|
||||
problems.append(
|
||||
|
||||
@@ -8,12 +8,33 @@ perfectly on a developer's laptop.
|
||||
Design bias: this page must be readable at the end of a long day, on a phone, in
|
||||
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
|
||||
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
|
||||
anything the panel exposes reach the inverter? - is answered by reading one
|
||||
short file.
|
||||
|
||||
⚠️ THE ANSWER TO THAT QUESTION, STATED SO IT CAN BE CHECKED: the configuration
|
||||
routes below read `GET /addons/self/info`, run the SHIPPED validator, write
|
||||
`POST /addons/self/options` and, on an explicitly confirmed request, `POST
|
||||
/addons/self/restart`. NONE of them writes a setpoint, clears a latch, changes a
|
||||
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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import selfconfig
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
@@ -49,6 +70,7 @@ PAGE = """<!doctype html>
|
||||
.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>
|
||||
|
||||
<div class="card">
|
||||
@@ -95,7 +117,33 @@ refresh(); setInterval(refresh, 3000);
|
||||
"""
|
||||
|
||||
|
||||
def build_app(controller) -> web.Application:
|
||||
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
|
||||
# validate.py's rules compare a configured ceiling against the setpoint
|
||||
# device's OWN reported range, and that range is only knowable by asking Home
|
||||
# Assistant. When ESPHome is asleep or the entity does not exist yet the
|
||||
# startup gate SKIPS those two rules rather than guessing - so the panel skips
|
||||
# them too, and says so, because a green page that is green for a reason the
|
||||
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report a range, so the two checks that compare a configured "
|
||||
"ceiling against what the device itself accepts were skipped - here and at "
|
||||
"startup, identically. Everything else was checked. If the device answers "
|
||||
"later and disagrees, the add-on refuses to start then, with the same "
|
||||
"message you would have seen here.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"""The ingress app. `selfcfg` is injectable so a check can drive it.
|
||||
|
||||
⚠️ IT IS BUILT FROM THE CONTROLLER'S OWN SESSION AND TOKEN, not from a
|
||||
second `ClientSession`. `amain()` owns one session for the process and every
|
||||
Home Assistant call already goes through it; a second one here would be a
|
||||
second connection pool whose failures nothing reports.
|
||||
"""
|
||||
if selfcfg is None:
|
||||
selfcfg = selfconfig.SelfConfig(controller.hass.session,
|
||||
controller.hass.token)
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
|
||||
@@ -113,17 +161,152 @@ def build_app(controller) -> web.Application:
|
||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||
return web.json_response({"ok": True, "result": result})
|
||||
|
||||
# --- UX-02: the configuration surface ---------------------------------
|
||||
async def config_index(_request):
|
||||
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
||||
|
||||
async def _proposed(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
options = body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
return body, options
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
|
||||
⚠️ `running_differs` IS NOT COSMETIC. `controller.o` is the blob this
|
||||
PROCESS started with (`/data/options.json`); `info["options"]` is what
|
||||
the Supervisor has STORED and would apply at the next start. They differ
|
||||
exactly when somebody has saved and not restarted - here, or in the
|
||||
Configuration tab. Printing the difference is the only way an operator
|
||||
finds out that the page they are reading is not describing the running
|
||||
system, and it is also how an edit made on the other surface becomes
|
||||
visible on this one.
|
||||
"""
|
||||
try:
|
||||
info = await selfcfg.info()
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("cannot read the add-on's own options: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
options = info.get("options") or {}
|
||||
lang = str(request.query.get("lang", "en") or "en")[:8]
|
||||
model = field_model(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
})
|
||||
return web.json_response(model)
|
||||
|
||||
async def config_validate(request):
|
||||
"""The SHIPPED validator, run against a proposed blob. Writes nothing.
|
||||
|
||||
⚠️ IT READS HOME ASSISTANT AND WRITES NOTHING ANYWHERE. The two
|
||||
device-range rules need `hass.limits()`, which is a GET of an entity's
|
||||
state. The page debounces, so a burst of typing is one call after the
|
||||
typing stops rather than one per keystroke.
|
||||
|
||||
⚠️ THE STRINGS ARE PASSED THROUGH UNTOUCHED. Not summarised, not
|
||||
renumbered, not mapped to a severity. validate.py's sentences are the
|
||||
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)
|
||||
problems, unread = await selfconfig.problems_and_ranges(
|
||||
options, controller.hass)
|
||||
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
return web.json_response(payload)
|
||||
|
||||
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
|
||||
# `App.options`, the merge of config.yaml's defaults with the stored
|
||||
# ones, so every option key is in it. Nothing here types a key name;
|
||||
# see configui.problems_by_field() for what is done with them.
|
||||
return sorted(options)
|
||||
|
||||
async def config_save(request):
|
||||
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
||||
body, options = await _proposed(request)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
hass=controller.hass)
|
||||
except selfconfig.SaveRefused as refused:
|
||||
if refused.kind == "stale":
|
||||
stored = refused.stored or {}
|
||||
return web.json_response(
|
||||
{"kind": "stale", "changed": refused.changed,
|
||||
"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)},
|
||||
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)
|
||||
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")
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
"""Restart the add-on, at the operator's explicit request.
|
||||
|
||||
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
|
||||
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
|
||||
the request must carry the intent token; a reload, a retry or a stray
|
||||
POST cannot spend a control interruption. It restarts THIS ADD-ON and
|
||||
nothing else: no setpoint is written from here, and a latched failsafe
|
||||
is restored from the Store on the way back up rather than cleared.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
try:
|
||||
await selfcfg.restart(str(body.get("confirm") or ""))
|
||||
except selfconfig.NotConfirmed as err:
|
||||
return web.json_response({"error": str(err)}, status=400)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("restart request failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("restart requested from the configuration panel")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
web.get("/status", status),
|
||||
web.post("/action", action),
|
||||
web.get("/config", config_index),
|
||||
web.get("/config/model", config_model),
|
||||
web.post("/config/validate", config_validate),
|
||||
web.post("/config/save", config_save),
|
||||
web.post("/config/restart", config_restart),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
async def start(controller, port: int = 8099):
|
||||
runner = web.AppRunner(build_app(controller))
|
||||
async def start(controller, port: int = 8099, selfcfg=None):
|
||||
runner = web.AppRunner(build_app(controller, selfcfg))
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
|
||||
@@ -108,6 +108,25 @@ options:
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
|
||||
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
|
||||
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
|
||||
# purpose: weighting the SPLIT of the site target between packs by ENERGY
|
||||
# headroom rather than by power rating. The two packs here are asymmetric in
|
||||
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
|
||||
# the datasheet and the serials - so a split by rating and a split by energy
|
||||
# hand the work to DIFFERENT packs, and the energy one is the question ("which
|
||||
# pack can sustain its share for the hour"). A missing cut-out would have to be
|
||||
# DEFAULTED, and one default for two batteries protects the shallower pack with
|
||||
# the deeper pack's number; a missing capacity has an honest answer instead -
|
||||
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
|
||||
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
|
||||
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
|
||||
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
|
||||
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
|
||||
# there is no split to weight, so the key would be one an operator can set and
|
||||
# nothing reads.
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
@@ -621,6 +640,12 @@ schema:
|
||||
# enforced per entry, a missing required key names the key, a scalar where the
|
||||
# list belongs is refused, and the UI renders it as a repeatable section.
|
||||
#
|
||||
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
|
||||
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
|
||||
# optional cut-out is a defaulted one and a default is one number for two
|
||||
# batteries, whereas an absent capacity has an honest answer - no opinion
|
||||
# about the split, every unit weighs 1.0, which is what shipped.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
@@ -642,6 +667,7 @@ schema:
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
|
||||
|
||||
@@ -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: eight sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: eleven 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
|
||||
@@ -180,16 +180,25 @@ UNIT_SOURCES = {
|
||||
"measured power (0x0518), so what it learns is about one "
|
||||
"device. The site-wide half moved to `sign_site`",
|
||||
"maintenance": "the monthly recalibration is a pack procedure",
|
||||
# OPT-09, and it is the entry a reader will want to check against
|
||||
# SAFETY-25's deferred loop-side reserve, because it has the same shape.
|
||||
"pack": "THIS pack's own state of charge against THIS pack's own "
|
||||
"`bms_soc_floor` - a fact about one battery in one cupboard, "
|
||||
"exactly as `device`'s rating is a fact about one box. It is "
|
||||
"not SAFETY-25's deferred `loop_soc_floor` reserve: no option "
|
||||
"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",
|
||||
}
|
||||
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 TEN claim sources are classified, and only those ten",
|
||||
check("all ELEVEN claim sources are classified, and only those eleven",
|
||||
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 four that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
check("the five 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 "
|
||||
@@ -207,7 +216,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 four "
|
||||
check("with TWO the identity breaks and the split becomes real: the five "
|
||||
"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}
|
||||
|
||||
@@ -475,13 +475,42 @@ def _power_domain(rows):
|
||||
|
||||
|
||||
_pw, _pf = _power_domain(_sw), _power_domain(_sf)
|
||||
check(f"THE TWO ARMS ARE BYTE-IDENTICAL IN THE POWER DOMAIN on all "
|
||||
f"{len(_pw)} cycles - grid, site target, every share, every measured "
|
||||
f"watt. No threshold on any of those numbers, at any N and any X, can "
|
||||
f"separate a wedged unit from a pack at its floor, and the card's own "
|
||||
f"proposed discriminator reads only those numbers",
|
||||
len(_pw) == len(_pf) and _pw == _pf)
|
||||
check("...and the ONLY thing that differs is the state of charge",
|
||||
# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING
|
||||
# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every
|
||||
# power number - the cold pack was ASKED for the same +2000 W the wedged one was
|
||||
# asked for, and both produced 0 W - and that identity was this file's headline.
|
||||
# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under
|
||||
# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two
|
||||
# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN
|
||||
# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the
|
||||
# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on
|
||||
# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at
|
||||
# the meter. No (N, X) separates them, exactly as measured on the rig - so the
|
||||
# claim is now stated on the two quantities the predicate actually reads rather
|
||||
# than on the whole trace, which is the stronger form of the same sentence.
|
||||
_ERR_W = 900.0
|
||||
_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)]
|
||||
_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)]
|
||||
print(f" the two quantities the card's predicate reads, last 8 cycles: "
|
||||
f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, "
|
||||
f"cold grid {[round(r['grid']) for r in _sf[-8:]]}")
|
||||
check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED "
|
||||
f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the "
|
||||
f"last {len(_sw[-8:])} cycles and |grid error| stands over "
|
||||
f"{_ERR_W:.0f} W on every one of them. No threshold on saturation "
|
||||
f"persistence and no threshold on grid error, at any N and any X, can "
|
||||
f"separate a wedged unit from a pack at its floor",
|
||||
[r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]]
|
||||
and len(_settled[0]) >= 8 and len(_settled[1]) >= 8)
|
||||
check("...and the SHARES now differ, which is OPT-09 and not a weakening of the "
|
||||
"line above: the cold pack is commanded 0 W because its own SoC closed "
|
||||
"its own envelope, while the wedged pack - whose SoC is healthy - is "
|
||||
"still asked for its full share and still delivers nothing. The two "
|
||||
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
|
||||
"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)
|
||||
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 "
|
||||
f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured "
|
||||
@@ -490,27 +519,48 @@ print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}")
|
||||
|
||||
check("AC 1: the WEDGED unit is NAMED, and only it",
|
||||
_sw[-1]["named"] == ("inverter-b",))
|
||||
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT - on the identical trace",
|
||||
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT",
|
||||
_sf[-1]["named"] == ())
|
||||
print(f" {_cw.inverters[1].delivery_wd.reason}")
|
||||
|
||||
# ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third
|
||||
# failure mode is a check whose data cannot reach the failure it names: "the
|
||||
# cold arm named nobody" reads exactly the same against a fixture where nothing
|
||||
# was short, nothing was asked, or the loop never ran. So the cold arm is
|
||||
# asserted to have REACHED the branch and been turned away by the account, not
|
||||
# to have missed it.
|
||||
# was short, nothing was asked, or the loop never ran.
|
||||
#
|
||||
# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE
|
||||
# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share,
|
||||
# was short of it, and was turned away by `account()` alone - the account was the
|
||||
# only thing standing between an empty battery and an alarm. It is not any more:
|
||||
# the pack's own envelope is closed, so it is never asked, `short_by()` answers
|
||||
# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not
|
||||
# asking - rather than by a discriminator applied after the fact.
|
||||
#
|
||||
# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09
|
||||
# being reverted or a pack whose numbers are absent: `account()` STILL turns this
|
||||
# pack away when the ask IS present, and that is asked of it directly here on
|
||||
# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the
|
||||
# predicate over its whole input space; this line pins it to THIS fixture's
|
||||
# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack.
|
||||
_cold_b = _cf.inverters[1]
|
||||
check("...and the cold arm REACHED that decision rather than falling short of "
|
||||
"it: B was asked for a discharge share, was short of it by more than "
|
||||
"saturation_w, and was turned away by `account()` alone",
|
||||
float(_cold_b.standing_w()) > 0
|
||||
and short_by(_cold_b.standing_w(), _cold_b.batt) > _cf.tuning.saturation_w
|
||||
and account(live=True, parked=False, soc=_cold_b.soc,
|
||||
bms_floor=_cold_b.bms_soc_floor) is not None)
|
||||
check("...and the WEDGED arm's B differs from it in exactly that one place",
|
||||
check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's "
|
||||
"own state of charge closed B's own envelope, so B is commanded 0 W and "
|
||||
"`short_by()` has no discharge share to be short of. The ask never "
|
||||
"arrives, which is a better answer than an alarm suppressed after it does",
|
||||
float(_cold_b.standing_w()) == 0.0
|
||||
and short_by(_cold_b.standing_w(), _cold_b.batt) is None)
|
||||
check("...and `account()` STILL turns this pack away if the ask does arrive - "
|
||||
"asked directly, on this arm's own measured SoC and its own configured "
|
||||
"cut-out. That is the half that must not depend on OPT-09: an unreadable "
|
||||
"SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, "
|
||||
"and the account is what keeps those quiet",
|
||||
account(live=True, parked=False, soc=_cold_b.soc,
|
||||
bms_floor=_cold_b.bms_soc_floor) is not None)
|
||||
check("...and the WEDGED arm's B differs from it in exactly that one place - "
|
||||
"which is why it is still asked for its share, and still named",
|
||||
account(live=True, parked=False, soc=_cw.inverters[1].soc,
|
||||
bms_floor=_cw.inverters[1].bms_soc_floor) is None)
|
||||
bms_floor=_cw.inverters[1].bms_soc_floor) is None
|
||||
and float(_cw.inverters[1].standing_w()) > 0)
|
||||
|
||||
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
|
||||
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
|
||||
@@ -694,11 +744,17 @@ check("...and each arm is a config validate.py rule 8 accepts: its "
|
||||
"peak_soc_floor is above EVERY unit's own cut-out",
|
||||
all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor)
|
||||
for c in (_c_deep, _c_shal) for i in c.inverters))
|
||||
check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - "
|
||||
"grid, site target, every share, every measured watt. The configured "
|
||||
"cut-out is the only thing that differs, which is part 4's claim made "
|
||||
"again at two cut-outs that are not the shipped one",
|
||||
_power_domain(_r_deep) == _power_domain(_r_shal))
|
||||
check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S "
|
||||
"PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of "
|
||||
"grid error on every one of the last eight cycles - which is part 4's "
|
||||
"claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR "
|
||||
"SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % "
|
||||
"against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against "
|
||||
"5 %); that is the site declining to ask, not a detector telling them "
|
||||
"apart, and it is why the pin below still needs the pack's OWN number",
|
||||
[r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]]
|
||||
and all(abs(r["grid"]) > 900.0
|
||||
for rows in (_r_deep, _r_shal) for r in rows[-8:]))
|
||||
|
||||
check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
|
||||
f"cold, and is NOT named - even though it is far above the "
|
||||
@@ -706,11 +762,14 @@ check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
|
||||
f"against, which would have called an empty pack a fault",
|
||||
_r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP
|
||||
and _bd.soc == _DEEP_SOC)
|
||||
check("...and it REACHED that decision: it was asked for a discharge share, "
|
||||
"was short of it by more than saturation_w, and was turned away by "
|
||||
"`account()` alone",
|
||||
float(_bd.standing_w()) > 0
|
||||
and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w
|
||||
check("...and it reaches that decision the way OPT-09 makes it reach it - the "
|
||||
"pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W "
|
||||
"and has no discharge share to be short of - while `account()`, asked "
|
||||
"directly on the same two numbers, STILL turns it away. Both halves, for "
|
||||
"the reason part 4 gives: the second is what survives OPT-09 being "
|
||||
"reverted, and it is what covers the absences OPT-09 will not close on",
|
||||
float(_bd.standing_w()) == 0.0
|
||||
and short_by(_bd.standing_w(), _bd.batt) is None
|
||||
and account(live=True, parked=False, soc=_bd.soc,
|
||||
bms_floor=_bd.bms_soc_floor) is not None)
|
||||
check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out "
|
||||
@@ -801,12 +860,21 @@ check("the saturation freeze: FIRES - and that is the point. It is the one "
|
||||
print(f" saturation counter {_cw.sat_count} (freezes at "
|
||||
f"{_cw.tuning.saturation_cycles}); site commanded "
|
||||
f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured")
|
||||
check("...and it fires IDENTICALLY on the benign arm, on the identical trace, "
|
||||
"at the same counter. A saturation-shaped alarm cannot tell these two "
|
||||
"apart at any threshold and any duration",
|
||||
_cf.sat_count == _cw.sat_count
|
||||
and _cf.site_batt_w == _cw.site_batt_w
|
||||
and _cf.target == _cw.target)
|
||||
print(f" ...and on the benign arm: counter {_cf.sat_count}, site "
|
||||
f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured")
|
||||
check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The "
|
||||
"site is commanded the same watts against the same measured watts and is "
|
||||
"frozen on both, many times over the configured cycle count. ⚠️ THE "
|
||||
"COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the "
|
||||
"cold arm reaches its steady state one cycle sooner because its healthy "
|
||||
"sibling gets the whole share immediately instead of half of it. A "
|
||||
"duration threshold that both arms exceed by an order of magnitude "
|
||||
"cannot be set between them, which is the claim - and the two settled "
|
||||
"states are identical to the watt",
|
||||
_cf.site_batt_w == _cw.site_batt_w
|
||||
and _cf.target == _cw.target
|
||||
and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles
|
||||
and abs(_cf.sat_count - _cw.sat_count) <= 1)
|
||||
|
||||
print()
|
||||
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
|
||||
|
||||
@@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||
"`int(0,50)?`, and the difference is the whole of that card: an optional "
|
||||
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS",
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS. "
|
||||
"⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: "
|
||||
"it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an "
|
||||
"honest answer - the add-on then has no opinion about the SPLIT and every "
|
||||
"unit weighs 1.0, which is exactly what shipped - where an absent cut-out "
|
||||
"would have to be invented. If you are here because you added a REQUIRED "
|
||||
"key, read `Inverter.__init__` before changing this line: a required key "
|
||||
"refuses every options blob written before it existed",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
"soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w",
|
||||
"bms_soc_floor"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh")))
|
||||
|
||||
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
|
||||
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# observe exactly {device, supervised, loop} at every SoC, because the shipped
|
||||
# roster is one inverter and this claim cannot be filed there at all. See
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
|
||||
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
|
||||
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
|
||||
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
|
||||
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
|
||||
# under its cut-out, `lo = 0` for one at 100 %.
|
||||
#
|
||||
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
|
||||
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
|
||||
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
|
||||
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
|
||||
# separate this from it, and all four are graded elsewhere in this repository:
|
||||
#
|
||||
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
|
||||
# register 0x0560 and required of every roster entry since SAFETY-32. No
|
||||
# new option is added and `peak_soc_floor` is deliberately NOT consulted
|
||||
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
|
||||
# is NOT closed, though a 20 % EMS floor would have closed it).
|
||||
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
|
||||
# INVERTER carries the target, never how big the target is.
|
||||
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
|
||||
# still observe exactly {device, supervised, loop} at every state of charge
|
||||
# from 0 to 100 % on the shipped install. Option A stands untouched there:
|
||||
# ordinary self-consumption still discharges to the BMS cut-out.
|
||||
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
|
||||
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
|
||||
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
|
||||
# stage 1's input is claims - so a unit-scoped claim is the shape that
|
||||
# sentence asks for.
|
||||
#
|
||||
# If you are here because you added a TWELFTH, that argument is what this line
|
||||
# wants from you, not an edit.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site"}
|
||||
"sign_site", "pack"}
|
||||
# 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.
|
||||
@@ -2619,10 +2652,11 @@ 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 nine. 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 "
|
||||
"NINTH source. Filing one takes this line red on the line it is written",
|
||||
check("...and the sources that cycle can file are EXACTLY these ELEVEN. 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 "
|
||||
"is written, and OPT-09's `pack` had to argue its way past it above",
|
||||
set(_filed) == CLAIM_SOURCES)
|
||||
|
||||
|
||||
@@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
|
||||
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
|
||||
# pinned to the AST-scanned set rather than trusted to agree with it.
|
||||
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
|
||||
"nothing else is. A TENTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a tenth - and it takes an "
|
||||
"nothing else is. A TWELFTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a twelfth - and it takes an "
|
||||
"argument about what the claim is ABOUT to make either line green",
|
||||
set(CLAIM_SCOPE) == CLAIM_SOURCES)
|
||||
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
@@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
+ " | per-inverter: "
|
||||
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
|
||||
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
|
||||
"claims and the four about a pack, a device or one write are "
|
||||
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
|
||||
"are site claims and the five about a pack, a device or one write are "
|
||||
"per-inverter. `circulation` is a site claim because its quantity is "
|
||||
"defined over the roster and is identically zero for any one unit; "
|
||||
"`sign_site` is one because the number that tripped it is the SITE "
|
||||
@@ -2769,7 +2803,7 @@ 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"})
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack"})
|
||||
|
||||
# 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
|
||||
@@ -2853,8 +2887,9 @@ _restore_b()
|
||||
print(" with a second inverter on the roster, main.py routes: "
|
||||
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
|
||||
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"inverter and its OWN routing method splits the ten: `device`, "
|
||||
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
|
||||
"inverter and its OWN routing method splits the eleven: `device`, "
|
||||
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
|
||||
"unit this controller "
|
||||
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
|
||||
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
|
||||
"them - route `peak` to the unit and the site keeps no floor; route "
|
||||
@@ -2865,6 +2900,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",
|
||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
|
||||
@@ -2948,16 +2984,16 @@ _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 TEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all ELEVEN 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) == 10
|
||||
len(_direct) == 11
|
||||
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 TEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
|
||||
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
|
||||
"the line the typo is written, at every roster, INCLUDING the six "
|
||||
@@ -2971,14 +3007,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. Five of the ten appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
|
||||
"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. "
|
||||
"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]) == 5)
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
|
||||
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 "
|
||||
@@ -2987,7 +3024,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 eleven appends account for exactly the nine "
|
||||
check("...and between them the twelve appends account for exactly the eleven "
|
||||
"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())
|
||||
@@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
|
||||
"connection",
|
||||
sorted((c.source, c.actuator) for c in _cl2)
|
||||
== [("device", "inverter-a"), ("device", "inverter-b"),
|
||||
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
|
||||
("loop", ACTUATOR_SITE),
|
||||
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
|
||||
# STATICALLY. This fixture leaves both packs at 100 % - the last value
|
||||
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
|
||||
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
|
||||
# actuator, which is the half a `pack` limit filed on the site would
|
||||
# fail, and it appears ONLY here: the one-inverter sweep above files
|
||||
# no `pack` claim at any state of charge, which is AC 4.
|
||||
("pack", "inverter-a"), ("pack", "inverter-b"),
|
||||
("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 ten sources need a state "
|
||||
"reason the static pin exists - SEVEN of the eleven 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 "
|
||||
@@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the "
|
||||
"inverter's reported range bound nothing at all",
|
||||
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
|
||||
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
|
||||
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
|
||||
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
|
||||
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
|
||||
"claims filed at it - are B's own, because a claim filed correctly and "
|
||||
"then resolved against the wrong envelope would be the same defect one "
|
||||
"layer down",
|
||||
"layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
|
||||
"second half is OPT-09 rather than a defect: both packs in this fixture "
|
||||
"sit at 100 % - the last value of the SoC sweep above - so each one's "
|
||||
"`pack` claim closes its charge end. The two units' envelopes are still "
|
||||
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
|
||||
"copied from the first unit would read +5000 on B",
|
||||
_n2_alloc is not None
|
||||
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
|
||||
is not None
|
||||
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
|
||||
and p.hi == B_RATING_HYPOTHESIS)(
|
||||
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
|
||||
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
|
||||
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
|
||||
and _n2_plans["inverter-a"] != _n2_plans["inverter-b"])
|
||||
|
||||
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
"""Runnable check for the ingress configuration surface. `python3 test_selfconfig.py`
|
||||
|
||||
UX-02. The panel edits, validates and saves the add-on's own options. Four of
|
||||
its properties are the ones that could hurt somebody, and they are what this
|
||||
file spends its length on:
|
||||
|
||||
1. AN INVALID BLOB IS NEVER WRITTEN. Not "warned about": not written. An
|
||||
invalid blob plus a restart is exit 78, and an add-on that is not running
|
||||
is not serving the panel either - the operator's way back would be the
|
||||
Supervisor UI, which is the surface this card exists because it is hard to
|
||||
read. Part 2 attempts the write and counts what the Supervisor received.
|
||||
2. A BLOB THAT MOVED UNDERNEATH IS NEVER CLOBBERED. There are two config
|
||||
surfaces and only one blob. Part 3 simulates the other one writing between
|
||||
this page's read and its save.
|
||||
3. EVERY MESSAGE COMES FROM THE SHIPPED VALIDATOR. Part 6 adds a NEW RULE to
|
||||
a COPY of validate.py, serves the page out of that copy in a subprocess,
|
||||
and watches the new sentence arrive on the field it names - with every UI
|
||||
file byte-identical, which is asserted by hashing them.
|
||||
4. NOTHING HERE COMMANDS AN INVERTER. Part 7 lists what the panel exposes and
|
||||
compares it with what `/action` already offered.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. That is TEST-15, and
|
||||
part 6 below builds a mutated tree on purpose, so it purges its own copy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import config_page, configui, selfconfig, web
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures.
|
||||
#
|
||||
# ⚠️ `SCHEMA` AND `TRANSLATIONS` ARE FIXTURES OF SOMEBODY ELSE'S RESPONSE SHAPE,
|
||||
# and they are graded against the real thing somewhere else on purpose.
|
||||
# `tools/supervisor_selfapi_probe.py` builds the same two structures with
|
||||
# Supervisor 2026.08.0's OWN `UiOptions` and `_read_app_translations` and runs
|
||||
# this repository's `field_model()` over them - §C and §E. This file cannot: the
|
||||
# CI image is Alpine + python3 + aiohttp + paho and has neither the Supervisor
|
||||
# nor PyYAML. So the shapes below are small, and the claim that they are the
|
||||
# shapes that arrive is the probe's, not this file's.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SCHEMA = [
|
||||
{"name": "setpoint_entity", "required": True, "type": "string"},
|
||||
{"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0,
|
||||
"required": True, "type": "integer"},
|
||||
{"name": "meter_source", "required": True, "type": "select",
|
||||
"options": ["off", "ha_dsmr"]},
|
||||
{"name": "log_level", "required": True, "type": "select",
|
||||
"options": ["info", "trace"]},
|
||||
{"name": "batt_invert", "required": True, "type": "boolean"},
|
||||
{"name": "inverters", "type": "schema", "optional": True, "multiple": True,
|
||||
"schema": [
|
||||
{"name": "name", "required": True, "type": "string"},
|
||||
{"name": "setpoint_entity", "required": True, "type": "string"},
|
||||
{"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0,
|
||||
"required": True, "type": "integer"},
|
||||
{"name": "bms_soc_floor", "lengthMin": 0.0, "lengthMax": 50.0,
|
||||
"required": True, "type": "integer"}]},
|
||||
]
|
||||
TRANSLATIONS = {
|
||||
"en": {"configuration": {
|
||||
"setpoint_entity": {"name": "Hardware · Inverter setpoint entity",
|
||||
"description": "Where the setpoint is written."},
|
||||
"max_w": {"name": "Limits · Inverter rating (W)",
|
||||
"description": "The ceiling this site may command."},
|
||||
"meter_source": {"name": "Meter · Source",
|
||||
"description": "Where the grid reading comes from."},
|
||||
"log_level": {"name": "Advanced · Log level",
|
||||
"description": "How much the add-on writes to its log."},
|
||||
"batt_invert": {"name": "Hardware · Battery power sign is reversed",
|
||||
"description": "Only if your sensor is backwards."},
|
||||
"inverters": {"name": "Hardware · Inverter list",
|
||||
"description": "Leave empty for one inverter.",
|
||||
"fields": {
|
||||
"name": {"name": "Name"},
|
||||
"setpoint_entity": {"name": "Setpoint entity"},
|
||||
"max_w": {"name": "Rating (W)"},
|
||||
"bms_soc_floor": {"name": "Battery cut-out (%)"}}}}},
|
||||
"nl": {"configuration": {
|
||||
"max_w": {"name": "Grenzen · Omvormervermogen (W)",
|
||||
"description": "Het plafond voor deze site."}}},
|
||||
}
|
||||
|
||||
|
||||
def base(**over):
|
||||
"""An options blob that STARTS. Everything else is this, spoilt.
|
||||
|
||||
The same fixture shape as test_validate.py's `base()`, for the same reason
|
||||
it gives: a check that is measuring a broken config has to be able to show
|
||||
that the unbroken one is accepted, or it is measuring nothing.
|
||||
"""
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "log_level": "info", "batt_invert": False,
|
||||
"inverters": [],
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
class FakeSupervisor:
|
||||
"""The Supervisor's stored options, and a count of what reached them.
|
||||
|
||||
⚠️ IT COUNTS WRITES, NOT CALLS. "Nothing was written" is the property parts
|
||||
2 and 3 are about, and a fake that only recorded that `write_options` was
|
||||
reached could not tell a refusal from a write that failed afterwards.
|
||||
"""
|
||||
|
||||
def __init__(self, options=None):
|
||||
self.options = dict(options or base())
|
||||
self.writes = []
|
||||
self.restarts = 0
|
||||
|
||||
async def info(self):
|
||||
return {"options": dict(self.options), "schema": SCHEMA,
|
||||
"translations": TRANSLATIONS, "state": "started"}
|
||||
|
||||
async def write_options(self, options):
|
||||
self.writes.append(dict(options))
|
||||
self.options = dict(options)
|
||||
|
||||
async def restart(self, confirm):
|
||||
if confirm != selfconfig.RESTART_CONFIRM:
|
||||
raise selfconfig.NotConfirmed("not confirmed")
|
||||
self.restarts += 1
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""`hass.limits()` and nothing else. It cannot write: that is the point."""
|
||||
|
||||
def __init__(self, ranges=None):
|
||||
self.ranges = ranges or {}
|
||||
self.reads = []
|
||||
|
||||
async def limits(self, entity):
|
||||
self.reads.append(entity)
|
||||
return self.ranges.get(entity, (None, None, None))
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, opts=None, hass=None):
|
||||
self.o = dict(opts or base())
|
||||
self.hass = hass or FakeHass()
|
||||
self.inverters = []
|
||||
self.events = []
|
||||
|
||||
def log_event(self, text):
|
||||
self.events.append(text)
|
||||
|
||||
def status_payload(self):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": []}
|
||||
|
||||
async def handle_action(self, what):
|
||||
raise AssertionError("the configuration surface must never reach "
|
||||
f"handle_action ({what!r})")
|
||||
|
||||
|
||||
# ⚠️ THE ANCHOR LIST IS EVERY OPTION KEY config.yaml DECLARES, read off the
|
||||
# file with the shipped parser rather than typed here. That is what the running
|
||||
# panel anchors against too: `GET /addons/self/info` returns the MERGED options,
|
||||
# so every key is present in the blob the page posts back. A test that anchored
|
||||
# against a small fixture's keys would report a rule as "unanchored" purely
|
||||
# because its fixture did not mention the key - which is what the first draft
|
||||
# of part 4 did, for the price rules.
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import translations_lint as _tl # noqa: E402
|
||||
|
||||
ALL_KEYS, _NESTED = _tl.config_surface(
|
||||
io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the compare-and-swap token")
|
||||
_a = base()
|
||||
_b = dict(reversed(list(base().items()))) # same content, other key order
|
||||
check("two blobs with the same content have the same fingerprint, whatever "
|
||||
"order their keys were built in - a token that changed on a dict "
|
||||
"reordering would refuse every save for no reason",
|
||||
selfconfig.fingerprint(_a) == selfconfig.fingerprint(_b))
|
||||
check("one changed value changes it",
|
||||
selfconfig.fingerprint(base()) != selfconfig.fingerprint(base(max_w=2001)))
|
||||
check("...and so does a changed ROSTER ORDER, because roster order is meaning",
|
||||
selfconfig.fingerprint(base(inverters=[{"name": "a"}, {"name": "b"}]))
|
||||
!= selfconfig.fingerprint(base(inverters=[{"name": "b"}, {"name": "a"}])))
|
||||
check("it is a hex digest and not the blob - nothing about the configuration "
|
||||
"leaves through it",
|
||||
re.fullmatch(r"[0-9a-f]{64}", selfconfig.fingerprint(base())) is not None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AN INVALID BLOB IS REFUSED, AND NOTHING IS WRITTEN")
|
||||
sup = FakeSupervisor()
|
||||
fp = selfconfig.fingerprint(sup.options)
|
||||
bad = base(setpoint_entity="") # rule 2: a controller with no actuator
|
||||
refused = None
|
||||
try:
|
||||
run(selfconfig.save(sup, bad, fp))
|
||||
except selfconfig.SaveRefused as err:
|
||||
refused = err
|
||||
check("the save raises SaveRefused(invalid)",
|
||||
refused is not None and refused.kind == "invalid")
|
||||
check("⚠️ AND THE SUPERVISOR RECEIVED NOTHING. Not a write that was rolled "
|
||||
"back, not a write of the old blob - no write at all",
|
||||
sup.writes == [])
|
||||
check("the stored options are byte-identical to what they were",
|
||||
selfconfig.fingerprint(sup.options) == fp)
|
||||
check("...and the refusal carries the validator's OWN sentence, unedited",
|
||||
refused.problems == validate_startup_config(bad, None, None))
|
||||
check("...which is the sentence that names the key and says what it costs",
|
||||
any("setpoint_entity is not set" in p for p in refused.problems))
|
||||
|
||||
_good = base()
|
||||
res = run(selfconfig.save(sup, _good, fp))
|
||||
check("a config that STARTS is saved - or nothing above means anything",
|
||||
len(sup.writes) == 1 and sup.writes[0] == _good)
|
||||
check("...and the caller gets the new token back, so the next save can "
|
||||
"compare against what it just wrote",
|
||||
res["fingerprint"] == selfconfig.fingerprint(_good))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE")
|
||||
# Review found that BOTH halves reverted with the whole 24-file suite still
|
||||
# green - including the half that turns a crash into a SILENT ACCEPTANCE, which
|
||||
# is the more dangerous of the two and was a wrong first attempt at this very
|
||||
# fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4.
|
||||
from app.inverter import _flat_max_w, build_roster # noqa: E402
|
||||
from app.validate import _number # noqa: E402
|
||||
|
||||
# ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges
|
||||
# does not raise" is ALSO satisfied by the backstop swallowing the exception and
|
||||
# returning [] - which is the silent-acceptance bug half two exists to prevent.
|
||||
# Measured: with BOTH halves reverted, every max_w check below reported ok and
|
||||
# only the forced-boom pins went red. So half one is pinned HERE, on the
|
||||
# composition itself, where the backstop cannot answer for it.
|
||||
_raised = None
|
||||
try:
|
||||
build_roster(base(max_w=""), None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised = exc
|
||||
check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the "
|
||||
"backstop above it. `validate.py`'s `_number()` reads '' as the default, "
|
||||
"so rule 9 sees 2000 and is content; a bare `float('')` in the flat path "
|
||||
"raised instead, inverting inverter.py's own rule that a shape rule 9 "
|
||||
"tolerates must be composable",
|
||||
_raised is None)
|
||||
|
||||
# HALF ONE: composition tolerates exactly what rule 9 tolerates.
|
||||
for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18):
|
||||
o = base(max_w=raw)
|
||||
expect = _number(o, "max_w", 2000.0)
|
||||
if expect is not None:
|
||||
check(f"_flat_max_w agrees with _number() for max_w={raw!r}",
|
||||
_flat_max_w(o) == expect)
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It "
|
||||
"used to raise ValueError from inside Controller.__init__, which the "
|
||||
"panel served as HTTP 500 on a non-JSON body and the page rendered as "
|
||||
"'cannot reach the add-on' - untrue, for the most prominent limit "
|
||||
"field's most likely transient state",
|
||||
_raised is None)
|
||||
check("...and it agrees with the validator, which reads '' as the default",
|
||||
_probs == list(validate_startup_config(base(max_w=""), None, None)))
|
||||
|
||||
_raised = None
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_raised, _probs = exc, None
|
||||
check("an UNPARSEABLE max_w answers with the validator's own sentence",
|
||||
_raised is None and any("is not a number" in p for p in (_probs or [])))
|
||||
|
||||
# HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING:
|
||||
# clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the
|
||||
# rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so
|
||||
# the rail collapses to 2000 in lockstep and rule 10 catches the collapse.
|
||||
_live = base(max_w=5000, slew_w=400)
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None))
|
||||
check("the live shape (5000 W rail, slew_w 400) starts", _probs == [])
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
|
||||
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
|
||||
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
|
||||
"the box cannot be emptied into a quietly halved rail",
|
||||
any("site rail of 2000 W" in p for p in _probs))
|
||||
|
||||
# HALF TWO: the backstop cannot report "no problems" for a config it could not
|
||||
# compose. Returning [] there enables Save - a crash became a green.
|
||||
_orig_roster = selfconfig.build_roster
|
||||
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise ValueError("forced boom")
|
||||
|
||||
|
||||
selfconfig.build_roster = _boom
|
||||
try:
|
||||
_probs, _ = run(selfconfig.problems_and_ranges(base(), None))
|
||||
finally:
|
||||
selfconfig.build_roster = _orig_roster
|
||||
check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be "
|
||||
"composed while the validator names no reason is itself the finding - "
|
||||
"returning [] would turn a crash into a silent acceptance and enable "
|
||||
"Save, which is worse than the 500 it replaced",
|
||||
_probs != [] and len(_probs) == 1)
|
||||
check("...and it says so in words, naming the exception",
|
||||
any("could not be composed" in p and "forced boom" in p
|
||||
for p in _probs))
|
||||
check("...and the patch was undone, so nothing below inherits it",
|
||||
selfconfig.build_roster is _orig_roster)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN")
|
||||
# ⚠️ SIMULATED AT THE EXACT MOMENT IT MATTERS. The Supervisor's Configuration
|
||||
# tab is not removable, so this is not a hypothetical race - it is the normal
|
||||
# way two people, or one person on two devices, use this add-on. The blob is
|
||||
# changed AFTER the page's read and BEFORE its save.
|
||||
# ⚠️ EVERY BLOB IN THIS SECTION MUST BE ONE THAT STARTS. The gate validates
|
||||
# BEFORE it compares, so a fixture that is also invalid would be refused for
|
||||
# the wrong reason and this section would be measuring rule 6 instead of the
|
||||
# compare-and-swap. (It was, in the first draft: `max_w: 1800` beneath the
|
||||
# fixture's `maintenance_charge_w: 2000` is a genuine refusal.)
|
||||
sup = FakeSupervisor(base(max_w=2000))
|
||||
page_fp = selfconfig.fingerprint(sup.options) # what the page loaded
|
||||
sup.options = base(max_w=2500) # the other tab saves
|
||||
mine = base(max_w=3000, log_level="trace") # what the operator typed
|
||||
refused = None
|
||||
try:
|
||||
run(selfconfig.save(sup, mine, page_fp))
|
||||
except selfconfig.SaveRefused as err:
|
||||
refused = err
|
||||
check("the save raises SaveRefused(stale)",
|
||||
refused is not None and refused.kind == "stale")
|
||||
check("⚠️ AND AGAIN NOTHING WAS WRITTEN - the other surface's value stands, "
|
||||
"and the operator's edits were not silently thrown at it",
|
||||
sup.writes == [] and sup.options["max_w"] == 2500)
|
||||
check("the refusal NAMES the keys that differ, so the page can print them "
|
||||
"rather than saying 'something changed'",
|
||||
refused.changed == ["log_level", "max_w"])
|
||||
check("...and carries what is stored, so the operator decides in front of the "
|
||||
"difference rather than through it",
|
||||
refused.stored["max_w"] == 2500)
|
||||
_after = run(selfconfig.save(sup, mine, selfconfig.fingerprint(sup.options)))
|
||||
check("saving again against the CURRENT token succeeds - the refusal is a "
|
||||
"gate, not a lock",
|
||||
len(sup.writes) == 1 and sup.options["max_w"] == 3000
|
||||
and _after["fingerprint"] == selfconfig.fingerprint(mine))
|
||||
|
||||
# ⚠️ AND THERE IS NO WAY ROUND IT. A caller with no token cannot save.
|
||||
sup = FakeSupervisor()
|
||||
try:
|
||||
run(selfconfig.save(sup, base(max_w=2400), ""))
|
||||
check("an empty token is refused", False)
|
||||
except selfconfig.SaveRefused as err:
|
||||
check("an empty token is refused as stale rather than treated as 'do not "
|
||||
"check' - there is no force door in this module",
|
||||
err.kind == "stale" and sup.writes == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. WHERE A REFUSAL LANDS ON THE PAGE")
|
||||
# ⚠️ MEASURED, NOT CLAIMED. The anchoring is name occurrence: a sentence is
|
||||
# printed under every option key it spells. Rather than assert a coverage
|
||||
# figure, this runs the SHIPPED validator on a spread of broken configs and
|
||||
# prints what anchored and what did not.
|
||||
_cases = [
|
||||
("no actuator", base(setpoint_entity="")),
|
||||
("max_w over the device", base(max_w=4000)),
|
||||
("charge rate above the ceiling", base(maintenance_charge_w=3000)),
|
||||
("sign threshold at the ceiling", base(sign_disagree_w=2000)),
|
||||
("two writers on one actuator",
|
||||
base(inverters=[{"name": "a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000, "bms_soc_floor": 10}])),
|
||||
("a price source that cannot mean what it says",
|
||||
base(price_provider="manual_csv")),
|
||||
]
|
||||
_ranges = {"number.goodwe_setpoint": (-3000.0, 3000.0, 1.0)}
|
||||
_total = _anchored = 0
|
||||
_orphans = []
|
||||
for title, opts in _cases:
|
||||
probs = run(selfconfig.problems_for(opts, FakeHass(_ranges)))
|
||||
by_key, un = configui.problems_by_field(probs, ALL_KEYS)
|
||||
_total += len(probs)
|
||||
_anchored += len(probs) - len(un)
|
||||
_orphans += [(title, p[:70]) for p in un]
|
||||
print(f" {title:34s} {len(probs)} problem(s), "
|
||||
f"{len(probs) - len(un)} beside a field")
|
||||
check(f"every one of the {_total} refusals these six broken configs produce "
|
||||
f"lands beside at least one of the fields it is about. Unanchored: "
|
||||
f"{_orphans}", _total > 0 and not _orphans)
|
||||
|
||||
_probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
|
||||
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
|
||||
check("the device-range rule is REACHED from the panel - the setpoint entity's "
|
||||
"own range is read, so rule 1 fires here exactly as it does at startup",
|
||||
any("THE DEVICE WINS" in p for p in _probs))
|
||||
check("...and it lands on `max_w`, which is the field the operator has to "
|
||||
"change", "max_w" in _by)
|
||||
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
|
||||
# is a suffix of both `unit_sign_disagree_w` and `site_sign_disagree_w` - three
|
||||
# real options, two of them SAFETY-30's. A plain substring test would print
|
||||
# every per-unit refusal next to the SITE field as well, which is a message
|
||||
# about unit-0 beside a box that has nothing to do with it. CI runs this file
|
||||
# and not the probe, so the assertion has to live here too.
|
||||
_collide = [k for k in ALL_KEYS
|
||||
if any(o != k and o.endswith("_" + k) for o in ALL_KEYS)]
|
||||
check(f"config.yaml really does declare keys that are suffixes of other keys, "
|
||||
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
||||
check("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
||||
"`sign_disagree_w`",
|
||||
sorted(configui.problems_by_field(
|
||||
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"],
|
||||
ALL_KEYS)[0]) == ["max_w", "unit_sign_disagree_w"])
|
||||
check("a cross-field refusal is filed under EVERY key it names, because a "
|
||||
"cross-field rule is about all of its fields",
|
||||
len(configui.problems_by_field(
|
||||
["max_w is 8000 W but number.x accepts at most 5000 W"],
|
||||
["max_w", "setpoint_entity"])[0]) == 1)
|
||||
check("the panel shows no refusal at all for a config that starts",
|
||||
run(selfconfig.problems_for(base(), FakeHass(_ranges))) == [])
|
||||
|
||||
# ⚠️ AND WHEN A DEVICE DOES NOT ANSWER, THE PAGE SAYS WHICH ONE. The two
|
||||
# device-range rules are then skipped - here and at startup, identically - so a
|
||||
# green page would be green for a reason the operator cannot see.
|
||||
_p, _unread = run(selfconfig.problems_and_ranges(base(max_w=4000), FakeHass()))
|
||||
check("with the setpoint device silent, rule 1 does not fire and the entity "
|
||||
"that did not answer is named, so the page can say what was skipped",
|
||||
_p == [] and _unread == ["number.goodwe_setpoint"])
|
||||
_p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
|
||||
FakeHass(_ranges)))
|
||||
check("...and with the same device answering, the same config IS refused - "
|
||||
"which is what makes the line above a skip rather than a pass",
|
||||
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
|
||||
check("the note is about the config UNDER TEST, not about the running roster: "
|
||||
"a newly typed entity that does not answer is the one named",
|
||||
run(selfconfig.problems_and_ranges(
|
||||
base(setpoint_entity="number.just_typed"), FakeHass(_ranges)))[1]
|
||||
== ["number.just_typed"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. THE UI OWNS NO COPY OF ANY RULE, LABEL OR KEY")
|
||||
_page = io.open(os.path.join(HERE, "app", "config_page.py"),
|
||||
encoding="utf-8").read()
|
||||
_ui_files = {name: io.open(os.path.join(HERE, "app", name),
|
||||
encoding="utf-8").read()
|
||||
for name in ("config_page.py", "configui.py", "web.py")}
|
||||
# The option keys config.yaml declares, read off the file rather than typed
|
||||
# here - a hand-written list would go stale in the direction that hides a
|
||||
# copy.
|
||||
# `name` and `max_w` also occur as ordinary English/JS words or as model field
|
||||
# names, so the test is on the SCRIPT half of the page and on keys that could
|
||||
# only be there deliberately.
|
||||
_script = _page.split("<script>", 1)[1]
|
||||
_leaked = sorted(k for k in ALL_KEYS
|
||||
if len(k) > 6 and re.search(r"[^A-Za-z0-9_]" + k + r"[^A-Za-z0-9_]",
|
||||
_script))
|
||||
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
|
||||
f"a field list of its own. Leaked: {_leaked}", not _leaked)
|
||||
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
|
||||
"setpoint_entity is not set", "would silently never happen"]
|
||||
_copied = sorted(p for p in _phrases
|
||||
for f, text in _ui_files.items() if p in text)
|
||||
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
|
||||
f"UI files - the sentences are fetched, never written here. Copied: "
|
||||
f"{_copied}", not _copied)
|
||||
# ⚠️ THIS ONE HAS ALREADY EARNED ITS KEEP. The suffix map shipped with
|
||||
# `("_hour", "h (0-23)")` in it - a RANGE, next to a field, in a second place
|
||||
# from config.yaml's `int(0,23)`. It is exactly the drift this card forbids,
|
||||
# in the one corner of the panel that is allowed to print anything of its own.
|
||||
check("no unit string contains a digit, so the unit map cannot be stating a "
|
||||
"bound, a default or a relation - config.yaml is the only place a range "
|
||||
"is written and the schema is where the form gets it from",
|
||||
all(not any(ch.isdigit() for ch in unit)
|
||||
for _suffix, unit in configui._UNITS))
|
||||
check("...and no entry of it is a whole option key - it is a suffix map, so it "
|
||||
"cannot become a per-key opinion by accident",
|
||||
not [suf for suf, _u in configui._UNITS if suf.lstrip("_") in ALL_KEYS])
|
||||
|
||||
# ⚠️ RELATIVE URLS, WHICH IS WHAT INGRESS REQUIRES. An absolute path works
|
||||
# perfectly on a laptop and 404s in the field, so it cannot be left to review.
|
||||
_abs = re.findall(r"""(?:fetch\(|href=)['"](/[^'"]*)['"]""",
|
||||
_page + _ui_files["web.py"])
|
||||
check(f"no absolute URL in either page - every fetch and every link is "
|
||||
f"relative, because ingress serves this under a generated path prefix. "
|
||||
f"Absolute: {_abs}", not _abs)
|
||||
check("the page declares a viewport, which is the whole of 'readable on the "
|
||||
"phone you are holding at the inverter'",
|
||||
'name="viewport"' in _page and "max-width:760px" in _page)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. ⚠️ A RULE ADDED TO validate.py APPEARS IN THE UI WITH NO UI CHANGE")
|
||||
# THE ACCEPTANCE CRITERION, RUN RATHER THAN ARGUED. A copy of this package is
|
||||
# made, ONE new rule is inserted into validate.py's `validate_startup_config()`,
|
||||
# and the real HTTP route is served out of that copy in a subprocess. If the
|
||||
# page carried any list of known rules, the new sentence would not arrive.
|
||||
SENTINEL = ("SENTINEL-UX02: log_level is set to 'trace', which this rule "
|
||||
"refuses so that a check can watch it arrive.")
|
||||
_INSERT = (' if str(opts.get("log_level", "")) == "trace":\n'
|
||||
f' problems.append({SENTINEL!r})\n')
|
||||
|
||||
_probe = r'''
|
||||
import asyncio, json, os, sys
|
||||
sys.path.insert(0, os.environ["TREE"])
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
from app import web
|
||||
|
||||
class H:
|
||||
session = None
|
||||
token = ""
|
||||
async def limits(self, e): return (None, None, None)
|
||||
class C:
|
||||
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
|
||||
def log_event(self, t): pass
|
||||
def status_payload(self): return {}
|
||||
async def handle_action(self, w): raise AssertionError("no")
|
||||
|
||||
async def main():
|
||||
app = web.build_app(C())
|
||||
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]
|
||||
async with ClientSession() as s:
|
||||
async with s.post(f"http://127.0.0.1:{port}/config/validate",
|
||||
json={"options": json.loads(os.environ["OPTS"])}) as r:
|
||||
print(json.dumps(await r.json()))
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(main())
|
||||
'''
|
||||
|
||||
_tmp = tempfile.mkdtemp(prefix="ux02-mutant-")
|
||||
try:
|
||||
_tree = os.path.join(_tmp, "goodwe_controller")
|
||||
shutil.copytree(os.path.join(HERE, "app"), os.path.join(_tree, "app"))
|
||||
# ⚠️ PURGE THE COPY'S BYTECODE. MUTATION.md habit: `python3 -B` stops
|
||||
# bytecode being WRITTEN, never a stale one being READ, and a copytree
|
||||
# brings any `__pycache__` along with it.
|
||||
_purged = 0
|
||||
for root, dirs, files in os.walk(_tree):
|
||||
for d in list(dirs):
|
||||
if d == "__pycache__":
|
||||
shutil.rmtree(os.path.join(root, d))
|
||||
dirs.remove(d)
|
||||
_purged += 1
|
||||
print(f" purged {_purged} __pycache__ director(y|ies) from the copy; "
|
||||
f"{sum(1 for r, _d, fs in os.walk(_tree) for f in fs if f.endswith('.pyc'))}"
|
||||
" .pyc left")
|
||||
|
||||
_vpath = os.path.join(_tree, "app", "validate.py")
|
||||
_src = io.open(_vpath, encoding="utf-8").read()
|
||||
_at = _src.index("def validate_startup_config")
|
||||
_anchor = " problems: list[str] = []\n"
|
||||
_cut = _src.index(_anchor, _at) + len(_anchor)
|
||||
io.open(_vpath, "w", encoding="utf-8").write(
|
||||
_src[:_cut] + _INSERT + _src[_cut:])
|
||||
check("the mutated copy differs from the shipped tree in validate.py, and "
|
||||
"in validate.py ONLY - so anything the page gains is the rule's",
|
||||
[name for name in sorted(os.listdir(os.path.join(HERE, "app")))
|
||||
if name.endswith(".py")
|
||||
and hashlib.sha256(io.open(os.path.join(HERE, "app", name), "rb")
|
||||
.read()).hexdigest()
|
||||
!= hashlib.sha256(io.open(os.path.join(_tree, "app", name), "rb")
|
||||
.read()).hexdigest()] == ["validate.py"])
|
||||
|
||||
_env = dict(os.environ, TREE=_tree, OPTS=json.dumps(base(log_level="trace")),
|
||||
PYTHONDONTWRITEBYTECODE="1")
|
||||
_out = subprocess.run([sys.executable, "-B", "-c", _probe], env=_env,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if _out.returncode != 0:
|
||||
print(_out.stdout[-2000:])
|
||||
print(_out.stderr[-2000:])
|
||||
check("the page's own /config/validate route answers out of the mutated "
|
||||
"tree", _out.returncode == 0)
|
||||
_payload = json.loads(_out.stdout.strip().splitlines()[-1]) if _out.stdout else {}
|
||||
check("⚠️ THE NEW RULE'S SENTENCE IS ON THE PAGE, and no file under app/ "
|
||||
"other than validate.py was touched to put it there",
|
||||
SENTINEL in (_payload.get("problems") or []))
|
||||
check("...and it is filed under `log_level`, the field it names, by the "
|
||||
"same anchoring that files every other rule - so a new rule arrives "
|
||||
"in the right place rather than in a catch-all",
|
||||
SENTINEL in (_payload.get("by_key") or {}).get("log_level", []))
|
||||
check("...while the SHIPPED tree does not refuse that config at all, which "
|
||||
"is what makes the line above evidence about the rule rather than "
|
||||
"about the fixture",
|
||||
run(selfconfig.problems_for(base(log_level="trace"), None)) == [])
|
||||
finally:
|
||||
shutil.rmtree(_tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. THE PANEL'S REACH INTO THE INVERTER IS UNCHANGED")
|
||||
_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",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
("GET", "/status"), ("POST", "/action"),
|
||||
("POST", "/config/restart"), ("POST", "/config/save"),
|
||||
("POST", "/config/validate")])
|
||||
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
|
||||
encoding="utf-8").read()
|
||||
for forbidden in ("set_number", "write_setpoint", "handle_action",
|
||||
"command_site_zero", "sign_wd", "reset("):
|
||||
check(f"neither selfconfig.py nor the page can reach `{forbidden}`",
|
||||
forbidden not in _selfconfig_src
|
||||
and forbidden not in _ui_files["config_page.py"])
|
||||
# ⚠️ ASSERTED ON THE CONSTANTS, NOT ON A GREP OF THE FILE. The module's own
|
||||
# docstring quotes Supervisor's bypass regex, which names `security`, `update`
|
||||
# and `options/validate`; a grep would find those and report a reach the code
|
||||
# does not have. The three constants are what `_call()` can be given.
|
||||
check("the ONLY Supervisor endpoints this module defines are self/info, "
|
||||
"self/options and self/restart",
|
||||
sorted(v for k, v in vars(selfconfig).items()
|
||||
if k.startswith("SELF_"))
|
||||
== ["http://supervisor/addons/self/info",
|
||||
"http://supervisor/addons/self/options",
|
||||
"http://supervisor/addons/self/restart"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8. end to end, over real HTTP, against a fake Supervisor")
|
||||
|
||||
|
||||
async def _serve(app):
|
||||
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 _e2e():
|
||||
out = {}
|
||||
sup = FakeSupervisor(base(max_w=2000))
|
||||
ctrl = FakeController(base(max_w=1500), # running != stored
|
||||
FakeHass({"number.goodwe_setpoint":
|
||||
(-3000.0, 3000.0, 1.0)}))
|
||||
runner, port = await _serve(web.build_app(ctrl, sup))
|
||||
root = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(f"{root}/config") as r:
|
||||
out["page"] = (r.status, await r.text())
|
||||
async with s.get(f"{root}/config/model?lang=nl") as r:
|
||||
out["model"] = await r.json()
|
||||
async with s.post(f"{root}/config/validate",
|
||||
json={"options": base(setpoint_entity="")}) as r:
|
||||
out["invalid"] = 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
|
||||
# went red only because a later, legitimate save had happened.
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(setpoint_entity=""),
|
||||
"fingerprint": out["model"]["fingerprint"]}
|
||||
) as r:
|
||||
out["save_invalid"] = (r.status, await r.json(),
|
||||
len(sup.writes))
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(max_w=2400),
|
||||
"fingerprint": "not-the-token"}) as r:
|
||||
out["save_stale"] = (r.status, await r.json(), len(sup.writes))
|
||||
async with s.post(f"{root}/config/save",
|
||||
json={"options": base(max_w=2400),
|
||||
"fingerprint": out["model"]["fingerprint"]}
|
||||
) as r:
|
||||
out["save_ok"] = (r.status, await r.json())
|
||||
async with s.post(f"{root}/config/restart", json={}) as r:
|
||||
out["restart_bare"] = (r.status, await r.json(), sup.restarts)
|
||||
async with s.post(f"{root}/config/restart",
|
||||
json={"confirm": "restart"}) as r:
|
||||
out["restart_ok"] = (r.status, await r.json())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
out["sup"] = sup
|
||||
out["ctrl"] = ctrl
|
||||
return out
|
||||
|
||||
|
||||
E = run(_e2e())
|
||||
check("GET config serves the page", E["page"][0] == 200
|
||||
and "GoodWe - Configuration" in E["page"][1])
|
||||
_m = E["model"]
|
||||
check("the model carries the groups, the stored blob and its token",
|
||||
# `Grenzen`, not `Limits`: this request asked for nl, and nl.yaml
|
||||
# carries `max_w`. The three groups around it fall back to English per
|
||||
# key, which is the frontend's own rule and is asserted two checks below.
|
||||
[g["name"] for g in _m["groups"]] == ["Hardware", "Grenzen", "Meter",
|
||||
"Advanced"]
|
||||
and _m["options"]["max_w"] == 2000
|
||||
and _m["fingerprint"] == selfconfig.fingerprint(_m["options"]))
|
||||
check("...and it says which stored settings are NOT what this add-on is "
|
||||
"running - which is how an edit made in the Configuration tab, or a save "
|
||||
"not yet restarted into, becomes visible here",
|
||||
_m["running_differs"] == ["max_w"] and _m["running"]["max_w"] == 1500)
|
||||
check("...and it honours the browser's language per key, falling back to "
|
||||
"English for the keys nl.yaml does not carry - the frontend's own rule",
|
||||
_m["lang"] == "nl"
|
||||
and [f["label"] for g in _m["groups"] for f in g["fields"]
|
||||
if f["key"] == "max_w"] == ["Omvormervermogen (W)"]
|
||||
and [f["label_lang"] for g in _m["groups"] for f in g["fields"]
|
||||
if f["key"] == "meter_source"] == ["en"])
|
||||
check("POST config/validate returns the validator's sentences, anchored",
|
||||
E["invalid"]["problems"]
|
||||
and "setpoint_entity" in E["invalid"]["by_key"])
|
||||
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"
|
||||
and E["save_invalid"][2] == 0)
|
||||
check("a save with a stale token answers 409, writes nothing, and hands back "
|
||||
"the CURRENT token so the operator can save again in front of the "
|
||||
"difference",
|
||||
E["save_stale"][0] == 409
|
||||
and E["save_stale"][1]["kind"] == "stale"
|
||||
and E["save_stale"][1]["fingerprint"]
|
||||
== selfconfig.fingerprint(E["save_stale"][1]["stored"])
|
||||
and E["save_stale"][2] == 0)
|
||||
check("a valid save with the right token is written, once",
|
||||
E["save_ok"][0] == 200 and len(E["sup"].writes) == 1
|
||||
and E["sup"].writes[0]["max_w"] == 2400)
|
||||
check("...and it does NOT restart anything - applying is a separate, "
|
||||
"confirmed act, because it costs a control interruption",
|
||||
E["restart_bare"][0] == 400 and E["restart_bare"][2] == 0)
|
||||
check("...which the confirmed request performs",
|
||||
E["restart_ok"][0] == 200)
|
||||
check("the action log records both, so §15 can say who changed what",
|
||||
any("configuration saved" in e for e in E["ctrl"].events)
|
||||
and any("restart requested" in e for e in E["ctrl"].events))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9. the page says what a save costs BEFORE it is pressed")
|
||||
for phrase in ("0 W on every inverter", "Saving does not restart anything",
|
||||
"Restart to apply"):
|
||||
check(f"the page states {phrase!r} in its own markup, not in a message "
|
||||
"that only appears afterwards", phrase in _page)
|
||||
check("the restart confirmation names the cost again at the moment of pressing",
|
||||
"Yes, restart now" in _page
|
||||
and _page.count("0 W on every inverter") >= 2)
|
||||
_flat = " ".join(_page.split())
|
||||
check("and it says which surface is authoritative when the add-on is down",
|
||||
"If the add-on is not running, the Configuration tab is the only one of "
|
||||
"the two that still exists" in _flat)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. the Supervisor conversation itself, over real HTTP")
|
||||
# ⚠️ EVERYTHING ABOVE INJECTED A FAKE `selfcfg`, so none of it exercised the
|
||||
# one piece that talks to somebody else's server: the headers, the request
|
||||
# body's shape, the `{"result": "ok", "data": {...}}` envelope and what a
|
||||
# refusal turns into. A gate whose transport is never run is a gate that has
|
||||
# only been read.
|
||||
|
||||
|
||||
async def _transport():
|
||||
seen = []
|
||||
|
||||
async def h_info(request):
|
||||
seen.append(("info", request.headers.get("Authorization"), None))
|
||||
return aioweb.json_response({"result": "ok",
|
||||
"data": {"options": {"max_w": 2000}}})
|
||||
|
||||
async def h_options(request):
|
||||
body = await request.json()
|
||||
seen.append(("options", request.headers.get("Authorization"), body))
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_restart(request):
|
||||
seen.append(("restart", request.headers.get("Authorization"), None))
|
||||
return aioweb.json_response({"result": "ok", "data": {}})
|
||||
|
||||
async def h_denied(_request):
|
||||
return aioweb.json_response(
|
||||
{"result": "error", "message": "Missing API permission"},
|
||||
status=403)
|
||||
|
||||
app = aioweb.Application()
|
||||
app.add_routes([aioweb.get("/addons/self/info", h_info),
|
||||
aioweb.post("/addons/self/options", h_options),
|
||||
aioweb.post("/addons/self/restart", h_restart),
|
||||
aioweb.get("/denied", h_denied)])
|
||||
runner, port = await _serve(app)
|
||||
root = f"http://127.0.0.1:{port}"
|
||||
saved = (selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
|
||||
selfconfig.SELF_RESTART)
|
||||
selfconfig.SELF_INFO = f"{root}/addons/self/info"
|
||||
selfconfig.SELF_OPTIONS = f"{root}/addons/self/options"
|
||||
selfconfig.SELF_RESTART = f"{root}/addons/self/restart"
|
||||
out = {}
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
cfg = selfconfig.SelfConfig(session, "TOKEN-123")
|
||||
out["info"] = await cfg.info()
|
||||
await cfg.write_options({"max_w": 2400})
|
||||
await cfg.restart(selfconfig.RESTART_CONFIRM)
|
||||
try:
|
||||
await cfg.restart("")
|
||||
out["unconfirmed"] = None
|
||||
except selfconfig.NotConfirmed as err:
|
||||
out["unconfirmed"] = str(err)
|
||||
try:
|
||||
await cfg._call("GET", f"{root}/denied")
|
||||
out["denied"] = None
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
out["denied"] = str(err)
|
||||
try:
|
||||
await cfg._call("GET", f"{root}/nothing-here")
|
||||
out["missing"] = None
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
out["missing"] = str(err)
|
||||
finally:
|
||||
(selfconfig.SELF_INFO, selfconfig.SELF_OPTIONS,
|
||||
selfconfig.SELF_RESTART) = saved
|
||||
await runner.cleanup()
|
||||
out["seen"] = seen
|
||||
return out
|
||||
|
||||
|
||||
T = run(_transport())
|
||||
check("`info()` unwraps the Supervisor's `{result, data}` envelope",
|
||||
T["info"] == {"options": {"max_w": 2000}})
|
||||
check("every request carries the add-on's own SUPERVISOR_TOKEN as a bearer",
|
||||
[auth for _what, auth, _b in T["seen"]] == ["Bearer TOKEN-123"] * 3)
|
||||
check("the options write goes to /addons/self/options as {'options': blob} - "
|
||||
"the shape `SCHEMA_OPTIONS` accepts, and the shape that REPLACES the "
|
||||
"stored user options",
|
||||
[(w, b) for w, _a, b in T["seen"] if w == "options"]
|
||||
== [("options", {"options": {"max_w": 2400}})])
|
||||
check("the restart is a POST to /addons/self/restart and reaches it only when "
|
||||
"confirmed",
|
||||
[w for w, _a, _b in T["seen"]] == ["info", "options", "restart"]
|
||||
and T["unconfirmed"])
|
||||
check("a Supervisor refusal is carried through with ITS message and its status "
|
||||
"rather than rewritten - the page shows what the Supervisor said",
|
||||
"403" in T["denied"] and "Missing API permission" in T["denied"])
|
||||
check("...and so is a 404, so a path this add-on is not permitted is "
|
||||
"distinguishable from one that does not exist",
|
||||
"404" in T["missing"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
|
||||
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||
"too, which is the half a top-level-only reader would miss",
|
||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||
and len(NESTED["inverters"]) == 7)
|
||||
and len(NESTED["inverters"]) == 8)
|
||||
|
||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||
"languages")
|
||||
@@ -248,10 +248,17 @@ for what, text in BAD:
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"70 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
|
||||
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
|
||||
# (`capacity_kwh`), and the two edits collided here on the rebase - each
|
||||
# restating a number the other had moved. The counts now come from the
|
||||
# same parse the assertion uses, so the sentence cannot drift from it
|
||||
# again. PROC-01.
|
||||
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
|
||||
f"per-entry keys came back out of each, which is what says the "
|
||||
f"strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
|
||||
for lg in LANGS))
|
||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||
@@ -355,7 +362,7 @@ print("\n7. no behaviour and no value moved")
|
||||
# test_peakguard.py's. This line is the cheap one that says the reorder did not
|
||||
# lose or duplicate a key on the way.
|
||||
check("no option was lost, gained or duplicated by the reordering: the two "
|
||||
"halves of config.yaml hold the same 70 keys, once each",
|
||||
"halves of config.yaml hold the same 71 keys, once each",
|
||||
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
||||
check("...and `inverters: []` is still shipped in the options half, which is "
|
||||
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
||||
|
||||
@@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
||||
"setpoint_entity": " "}]),
|
||||
-5000, 5000), "has no setpoint_entity"))
|
||||
|
||||
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
||||
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
||||
# RUN, which is the bar every clause of this rule is held to. A capacity that is
|
||||
# not a usable number is a pack the split would quietly stop preferring while
|
||||
# the operator can see a value in the box; a capacity on SOME entries is worse,
|
||||
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
|
||||
# that case - so every key the operator filled in does exactly nothing and
|
||||
# nothing says so.
|
||||
def _with_cap(*caps):
|
||||
o = {k: v for k, v in _PER_INVERTER_OK.items()}
|
||||
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
|
||||
for entry, cap in zip(o["inverters"], caps):
|
||||
if cap is not None:
|
||||
entry["capacity_kwh"] = cap
|
||||
return o
|
||||
|
||||
|
||||
check("a roster stating a capacity on EVERY entry is accepted - and the two "
|
||||
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
|
||||
"two per-unit numbers are equal cannot show a check reading the wrong "
|
||||
"unit's",
|
||||
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
|
||||
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
|
||||
"means: the add-on has no opinion about the split and every unit weighs "
|
||||
"1.0, exactly as it did before this card",
|
||||
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
|
||||
check("a capacity that is not a usable number of kWh is REFUSED, and the "
|
||||
"refusal names the entry - zero, negative and a word alike",
|
||||
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
|
||||
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
|
||||
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
|
||||
"on the other. It is the clause that costs an operator something, and it "
|
||||
"is the one worth having: a pack measured in kWh beside a sibling "
|
||||
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
||||
"silently change nothing at all",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"not PARTIAL")
|
||||
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
||||
"not PARTIAL"))
|
||||
check("...and the refusal names WHICH entry is which, because an operator with "
|
||||
"three inverters told 'one of them is wrong' is sent to the wrong "
|
||||
"cupboard",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-a")
|
||||
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-b"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. a meter that can never read")
|
||||
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
|
||||
|
||||
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
|
||||
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
|
||||
"a check whose DATA cannot reach the failure it names" - which this repository
|
||||
has recorded eleven times. So every refusal below is DRIVEN: a repository is
|
||||
built in which the failure is really present, and the check is required to say
|
||||
so. A clearance is only meaningful next to a refusal from the same harness.
|
||||
|
||||
The third property is the one that is easy to lose: a SKIPPED check must not
|
||||
look like a PASSED one. Omitting `--options` cannot exit 0.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
fails = []
|
||||
tmpdirs = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def make_repo(version="0.7.0"):
|
||||
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
|
||||
root = tempfile.mkdtemp(prefix="test_version_gate.")
|
||||
tmpdirs.append(root)
|
||||
addon = os.path.join(root, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon, "app"))
|
||||
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
|
||||
write(addon, "app/control.py", "X = 1\n")
|
||||
write(addon, "CHANGELOG.md", "# Changelog\n")
|
||||
git(root, "init", "-q", ".")
|
||||
git(root, "add", "-A")
|
||||
git(root, "commit", "-q", "-m", f"initial at {version}")
|
||||
return root, addon
|
||||
|
||||
|
||||
def write(addon, rel, text):
|
||||
p = os.path.join(addon, rel)
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def run_gate(addon, root, deployed, options=None):
|
||||
"""Run the REAL gate against a throwaway tree.
|
||||
|
||||
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
|
||||
imports the validator from its own sibling `app/`, which is the behaviour
|
||||
that matters: the gate must check a config against the validator it ships
|
||||
beside, not against whatever happens to sit next to a copy. Copying it into
|
||||
the fixture made every validator assertion silently SKIP - the first draft
|
||||
of this file did exactly that and reported four green skips as passes.
|
||||
"""
|
||||
cmd = [sys.executable, GATE,
|
||||
"--deployed-version", deployed, "--tree", addon, "--repo", root]
|
||||
if options:
|
||||
cmd += ["--options", options]
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
|
||||
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
|
||||
|
||||
|
||||
def opts_file(d):
|
||||
fd, p = tempfile.mkstemp(suffix=".json")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(d, fh)
|
||||
return p
|
||||
|
||||
|
||||
print("classification - what forces a rebuild and what does not")
|
||||
from predeploy_check import is_runtime, shipped_version # noqa: E402
|
||||
|
||||
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
|
||||
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
|
||||
check(f"runtime: {p}", is_runtime(p))
|
||||
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
|
||||
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
|
||||
"goodwe_controller/DECISIONS/a.md", "README.md"):
|
||||
check(f"NOT runtime: {p}", not is_runtime(p))
|
||||
|
||||
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
|
||||
root, addon = make_repo("0.7.0")
|
||||
write(addon, "app/control.py", "X = 2\n")
|
||||
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
|
||||
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
|
||||
check("refuses (exit 1)", r.returncode == 1)
|
||||
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
|
||||
check("...and names the offending commit", "SAFETY-99" in r.stdout)
|
||||
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
|
||||
|
||||
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
|
||||
root2, addon2 = make_repo("0.7.0")
|
||||
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
|
||||
git(root2, "commit", "-qam", "PKG-99: documentation only")
|
||||
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
|
||||
check("does not refuse a docs-only tree", r2.returncode == 0)
|
||||
check("...and says there is nothing to deploy",
|
||||
"nothing to deploy" in r2.stdout)
|
||||
|
||||
print("\nTHE CLEARANCE - a bumped version with a valid config")
|
||||
root3, addon3 = make_repo("0.7.0")
|
||||
write(addon3, "app/control.py", "X = 3\n")
|
||||
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
|
||||
git(root3, "commit", "-qam", "PKG-99: change and bump together")
|
||||
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
|
||||
check("clears (exit 0)", r3.returncode == 0)
|
||||
check("...and says so", "CLEARED for deploy" in r3.stdout)
|
||||
|
||||
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
|
||||
r4 = run_gate(addon3, root3, "0.7.0") # no --options
|
||||
check("omitting --options cannot exit 0", r4.returncode != 0)
|
||||
check("...says SKIPPED", "SKIPPED" in r4.stdout)
|
||||
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
|
||||
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
|
||||
|
||||
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
|
||||
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
|
||||
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
|
||||
check("refuses a config the validator rejects", r5.returncode == 1)
|
||||
check("...and warns it is exit 78", "78" in r5.stdout)
|
||||
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
||||
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
|
||||
|
||||
print("\nthe shipped tree's own version is readable")
|
||||
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,195 +0,0 @@
|
||||
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
|
||||
|
||||
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
|
||||
|
||||
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
|
||||
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
|
||||
loudly, and exit 2, never a silent pass. To get both halves:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
|
||||
run, and that is not the same as passing.
|
||||
|
||||
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
|
||||
|
||||
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
|
||||
config.yaml. `ha apps update` against an unchanged version does nothing and
|
||||
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with four merges behind
|
||||
an unbumped version; PKG-07 recorded it again on 2026-09-06 with six. Both
|
||||
times a human noticed. Nothing else was ever going to.
|
||||
|
||||
2. THE REFUSED CONFIG. A config the shipped validator rejects is
|
||||
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
|
||||
WITH IT, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
|
||||
started fine on the previous version: it refuses an explicit `slew_w` above
|
||||
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
|
||||
touches runtime code must bump the version" - is the WRONG shape for this
|
||||
project, which deliberately batches many merges into one deploy. It would demand
|
||||
a bump per pull request, put every branch in conflict over one line of
|
||||
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
|
||||
and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||
the operator exports it and passes `--options`. Without that flag the validator
|
||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ADDON = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ADDON)
|
||||
|
||||
# Files that end up in the built image and can change what it DOES. A change to
|
||||
# any of these needs a new `version:` or the Supervisor will not rebuild.
|
||||
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
|
||||
# scenarios/ are deliberately absent: they ship in the repo but cannot change
|
||||
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
|
||||
# without reading, which is the habit this file exists to prevent.
|
||||
RUNTIME = (
|
||||
"goodwe_controller/app/",
|
||||
"goodwe_controller/translations/",
|
||||
"goodwe_controller/config.yaml",
|
||||
"goodwe_controller/Dockerfile",
|
||||
"goodwe_controller/run.sh",
|
||||
"goodwe_controller/requirements.txt",
|
||||
"goodwe_controller/build.yaml",
|
||||
)
|
||||
|
||||
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
|
||||
|
||||
|
||||
def shipped_version(tree=ADDON):
|
||||
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
|
||||
m = _VER.search(fh.read())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def is_runtime(path):
|
||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||
|
||||
|
||||
def _git(*args, repo=None):
|
||||
return subprocess.run(["git", "-C", repo or os.path.dirname(ADDON), *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def runtime_commits_since_version_change(repo=None):
|
||||
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
|
||||
|
||||
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
|
||||
clean; an empty list with None means git could not answer and the caller
|
||||
must NOT read that as a pass.
|
||||
"""
|
||||
r = _git("log", "-1", "--format=%H", "-S", 'version: "',
|
||||
"--", "goodwe_controller/config.yaml", repo=repo)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
return [], None
|
||||
last = r.stdout.strip()
|
||||
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return [], None
|
||||
out = []
|
||||
for line in r.stdout.splitlines():
|
||||
sha, _, subject = line.partition(" ")
|
||||
f = _git("show", "--name-only", "--format=", sha, repo=repo)
|
||||
if any(is_runtime(p) for p in f.stdout.split()):
|
||||
out.append((sha[:10], subject))
|
||||
return out, last[:10]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--deployed-version", required=True,
|
||||
help="the version currently running ON THE BOX")
|
||||
ap.add_argument("--options", help="path to the live options blob, exported "
|
||||
"from the Supervisor. Omitted = that half is SKIPPED.")
|
||||
ap.add_argument("--tree", default=ADDON)
|
||||
ap.add_argument("--repo", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
problems, skipped = [], []
|
||||
shipped = shipped_version(a.tree)
|
||||
print(f" deployed on the box : {a.deployed_version}")
|
||||
print(f" shipped in this tree: {shipped}")
|
||||
|
||||
commits, last = runtime_commits_since_version_change(a.repo)
|
||||
if shipped is None:
|
||||
problems.append("could not read `version:` out of config.yaml")
|
||||
elif shipped == a.deployed_version:
|
||||
if last is None:
|
||||
skipped.append("could not ask git what changed since the last bump; "
|
||||
"the no-op check is INCONCLUSIVE, not clean")
|
||||
if commits:
|
||||
problems.append(
|
||||
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
|
||||
f"{len(commits)} commit(s) touching runtime files have landed since the "
|
||||
f"version last changed ({last}). `ha apps update` will report success "
|
||||
f"and install NOTHING. Bump `version:` first.")
|
||||
for sha, subj in commits[:12]:
|
||||
print(f" {sha} {subj[:76]}")
|
||||
elif last is not None:
|
||||
print(" no runtime change since the last bump - nothing to deploy")
|
||||
|
||||
if a.options:
|
||||
with open(a.options, encoding="utf-8") as fh:
|
||||
opts = json.load(fh)
|
||||
try:
|
||||
from app.validate import validate_startup_config
|
||||
except Exception as exc: # pragma: no cover
|
||||
skipped.append(
|
||||
f"could not import the shipped validator ({exc}); the config check "
|
||||
f"did NOT run. `app/validate.py` imports its siblings, and those need "
|
||||
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
|
||||
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
|
||||
f"or in the CI image. Re-run it as:\n"
|
||||
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
|
||||
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
|
||||
f" --deployed-version <ver> --options /w/<options.json>")
|
||||
else:
|
||||
found = validate_startup_config(opts)
|
||||
if found:
|
||||
problems.append(
|
||||
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
|
||||
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
|
||||
f"add-on does not start, and the ingress panel goes with it because "
|
||||
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
|
||||
for p in found:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" live options pass this version's validator")
|
||||
else:
|
||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||
"against this version's validator. A config refused at startup is "
|
||||
"exit 78. Export the options and re-run before deploying.")
|
||||
|
||||
print()
|
||||
for s in skipped:
|
||||
print(f" ⚠️ SKIPPED: {s}")
|
||||
if problems:
|
||||
for p in problems:
|
||||
print(f" ❌ {p}")
|
||||
print("\n REFUSED - do not deploy.")
|
||||
return 1
|
||||
if skipped:
|
||||
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
|
||||
"made without them.")
|
||||
return 2
|
||||
print(" CLEARED for deploy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,334 @@
|
||||
"""UX-02: can this add-on read and write its OWN options, and what comes back?
|
||||
|
||||
⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`,
|
||||
`POST /addons/self/options` and `POST /addons/self/restart` are permitted to an
|
||||
add-on with `hassio_api: true` and no special `hassio_role`, and it says - in
|
||||
its own words - "confirm this again in the PR by calling it, rather than citing
|
||||
this card". A permission read off a regex in somebody else's repository is a
|
||||
hypothesis; the whole configuration surface rests on it, so it is RUN here,
|
||||
against the compiled patterns out of the release image this site runs.
|
||||
|
||||
It also answers the question the panel's design depends on next: WHAT the info
|
||||
response carries. app/configui.py builds the entire form from `schema` and
|
||||
`translations` and owns no copy of either, which is only sound if those two
|
||||
really are in the response and really are the loader's own output. Both are
|
||||
constructed here, out of the image, and this repository's own model is built
|
||||
from them.
|
||||
|
||||
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
||||
2026.08.0 is what 192.168.2.158 ran when UX-02 landed, and it is the same image
|
||||
tools/supervisor_shape_probe.py pins):
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_selfapi_probe.py
|
||||
|
||||
⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads
|
||||
config.yaml and translations/, imports Supervisor's own modules and runs them in
|
||||
a throwaway container. It opens no socket, contacts no Supervisor, POSTs no
|
||||
options and restarts nothing. The add-on is live in a real house; the only
|
||||
honest way to measure this is against a copy of the software, not against it.
|
||||
|
||||
⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request
|
||||
against a running Supervisor, because doing that would mean pointing it at the
|
||||
live one. So §A grades the ACCESS DECISION (the compiled patterns, run against
|
||||
the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data`
|
||||
builds its dict from), rather than a captured response body. What is NOT
|
||||
verified here is the wiring between the two - that the handler which builds that
|
||||
dict is the one mounted at that path. That is read, not run, and this is the
|
||||
sentence saying so.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
sys.path.insert(0, "/") # app/ is mounted at /app
|
||||
|
||||
import voluptuous as vol # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from supervisor.utils.yaml import read_yaml_file # noqa: E402
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402
|
||||
from supervisor.apps.options import AppOptions, UiOptions # noqa: E402
|
||||
from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402
|
||||
from supervisor.store.data import _read_app_translations # noqa: E402
|
||||
from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402
|
||||
ATTR_TRANSLATIONS)
|
||||
from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402
|
||||
|
||||
from app import configui # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
|
||||
|
||||
def show(title, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {title}")
|
||||
if detail:
|
||||
print(" " + str(detail)[:400])
|
||||
if not ok:
|
||||
FAILED.append(title)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("== A. THE PERMISSION, RUN RATHER THAN READ ==")
|
||||
# ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped.
|
||||
# `token_validation()` tests `api_bypass` against `request.path` for an
|
||||
# installed app BEFORE it consults `access_hassio_api` or any role, so a match
|
||||
# here is the whole permission question for these paths.
|
||||
for path, want in (("/addons/self/info", True),
|
||||
("/addons/self/options", True),
|
||||
("/addons/self/restart", True),
|
||||
("/addons/self/options/config", True),
|
||||
# The two the pattern explicitly excludes, asserted so that
|
||||
# a future widening of it does not go unnoticed here.
|
||||
("/addons/self/security", False),
|
||||
("/addons/self/update", False)):
|
||||
got = bool(_V1_PATTERNS.api_bypass.match(path))
|
||||
show(f"{path:32s} bypass={got}", got == want)
|
||||
|
||||
# ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING.
|
||||
# Supervisor has its OWN options validator at `/addons/{app}/options/validate`.
|
||||
# It is two path segments after `self`, so `[^/]+` cannot match it, and it is
|
||||
# not one of the listed literals either. An add-on cannot call it. That does
|
||||
# not hurt this card - the Supervisor validator knows only types and ranges, and
|
||||
# the whole reason this panel exists is the cross-field rules it structurally
|
||||
# cannot express - but "there is exactly one validator reachable from inside the
|
||||
# add-on, and it is ours" is a fact the design should rest on knowingly.
|
||||
_val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate"))
|
||||
show("/addons/self/options/validate is NOT reachable from an add-on - so the "
|
||||
"ONLY validator this panel can call is app/validate.py, which is the one "
|
||||
"with the cross-field rules", not _val, f"bypass={_val}")
|
||||
|
||||
# ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE
|
||||
# THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default`
|
||||
# role admits none of the three, and that FAILED: the default role's pattern is
|
||||
# `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the
|
||||
# split is:
|
||||
#
|
||||
# /addons/self/info - admitted by BOTH the bypass and the default role
|
||||
# /addons/self/options - the bypass ONLY
|
||||
# /addons/self/restart - the bypass ONLY
|
||||
#
|
||||
# i.e. the two WRITING paths rest entirely on the bypass list, and if it were
|
||||
# narrowed this panel could still read its own options and could no longer save
|
||||
# them. That is a more useful thing to know than the tidier claim that was
|
||||
# asserted first, and it is only known because the claim was run.
|
||||
_default = _V1_PATTERNS.role_access["default"]
|
||||
show("`/addons/self/info` is admitted by the DEFAULT role as well as by the "
|
||||
"bypass (`/.+/info`) - so reading has two independent licences",
|
||||
bool(_default.match("/addons/self/info")))
|
||||
show("...while `/addons/self/options` and `/addons/self/restart` are admitted "
|
||||
"by the BYPASS ONLY. The two writing paths rest on that list alone",
|
||||
not _default.match("/addons/self/options")
|
||||
and not _default.match("/addons/self/restart"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==")
|
||||
# `AppsAPI.info_data()` builds its dict with these constants as keys. Their
|
||||
# VALUES are what the panel reads off the JSON, so they are asserted here
|
||||
# rather than typed into app/selfconfig.py as a guess.
|
||||
show("the response keys the panel reads are named `options`, `schema` and "
|
||||
"`translations`",
|
||||
(ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS)
|
||||
== ("options", "schema", "translations"),
|
||||
f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==")
|
||||
raw = read_yaml_file(Path("/w/config.yaml"))
|
||||
cfg = SCHEMA_APP_CONFIG(raw)
|
||||
schema, defaults = cfg["schema"], cfg["options"]
|
||||
ui = UiOptions(None)(schema)
|
||||
show(f"UiOptions renders every key config.yaml declares ({len(ui)})",
|
||||
len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}")
|
||||
|
||||
# ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` /
|
||||
# `lengthMax` - which reads like a string length and is not - and app/configui.py
|
||||
# copies those two names straight through into the form's `min`/`max`. Guessing
|
||||
# `valueMin` would have produced a form with no bounds at all and no error
|
||||
# anywhere.
|
||||
_maxw = [n for n in ui if n["name"] == "max_w"][0]
|
||||
show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), "
|
||||
"which is why app/configui.py reads those two names",
|
||||
_maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000,
|
||||
json.dumps(_maxw))
|
||||
_sel = [n for n in ui if n["name"] == "meter_source"][0]
|
||||
show("an enum arrives as type `select` with its `options` listed",
|
||||
_sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []),
|
||||
json.dumps(_sel))
|
||||
_inv = [n for n in ui if n["name"] == "inverters"][0]
|
||||
show("the roster arrives as type `schema` + `multiple`, carrying its per-entry "
|
||||
"fields - which is what the panel's roster editor draws cards from",
|
||||
_inv.get("type") == "schema" and _inv.get("multiple") is True
|
||||
and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity",
|
||||
"max_w"],
|
||||
json.dumps([s["name"] for s in _inv["schema"]]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==")
|
||||
# ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is
|
||||
# `deepmerge(config.yaml defaults, the STORED user options)` and the setter is
|
||||
# `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a
|
||||
# POST does not keep its previous value; it falls back to the config.yaml
|
||||
# DEFAULT. Both halves are run below on real objects.
|
||||
_persist_before = {"max_w": 4200, "gain": 0.42}
|
||||
_effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_before))
|
||||
show("stored user options merge OVER the config.yaml defaults, so every key is "
|
||||
"present in what an add-on reads back",
|
||||
_effective_before["max_w"] == 4200
|
||||
and set(_effective_before) == set(defaults),
|
||||
f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}")
|
||||
|
||||
_persist_after = {"gain": 0.42} # a POST that simply omits max_w
|
||||
_effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_after))
|
||||
show("...and a POST that OMITS a key does not preserve it - the key reverts to "
|
||||
"config.yaml's default. That is why this panel posts the WHOLE blob it "
|
||||
"read, and why it refuses to post one built on a stale read",
|
||||
_effective_after["max_w"] == defaults["max_w"] != 4200,
|
||||
f"omitted max_w -> {_effective_after['max_w']} "
|
||||
f"(default {defaults['max_w']}, was 4200)")
|
||||
|
||||
# The blob the panel would actually POST: the full merged options, unchanged.
|
||||
# If Supervisor refused that shape the read-modify-write would be unusable.
|
||||
_v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
_validated = _v(copy.deepcopy(_effective_before))
|
||||
show("the WHOLE merged blob - what this panel sends back - is accepted by "
|
||||
"Supervisor's own options validator", True)
|
||||
except vol.Invalid as err:
|
||||
_validated = None
|
||||
show("the WHOLE merged blob is accepted", False, str(err))
|
||||
|
||||
# ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the
|
||||
# operator which settings are "stored but not running" by comparing the stored
|
||||
# blob with `/data/options.json`. Supervisor writes that file through
|
||||
# `schema.validate(self.options)`, so any COERCION in that step would show up
|
||||
# as a spurious difference on every page load.
|
||||
if _validated is not None:
|
||||
_drift = sorted(k for k in set(_validated) | set(_effective_before)
|
||||
if _validated.get(k) != _effective_before.get(k))
|
||||
show("...and validation does not COERCE it, so `stored vs running` compares "
|
||||
"like with like and shows a difference only when there is one. "
|
||||
f"Keys changed by validation: {_drift}", not _drift)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==")
|
||||
translations = _read_app_translations(Path("/w"))
|
||||
show("Supervisor's own loader accepts both files, so `info['translations']` "
|
||||
"carries them", set(translations) == {"en", "nl"}, sorted(translations))
|
||||
|
||||
for lang, groups_expected in (("en", 7), ("nl", 7)):
|
||||
model = configui.field_model(ui, translations, lang)
|
||||
names = [g["name"] for g in model["groups"]]
|
||||
show(f"[{lang}] this repository's own field_model() turns those into "
|
||||
f"{len(names)} real section headings out of UX-01's `Group · ` prefix "
|
||||
f"- which the Supervisor's flat list cannot render at all",
|
||||
len(names) == groups_expected and not model["ungrouped"],
|
||||
f"{names} ungrouped={model['ungrouped']}")
|
||||
_raw = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if f["label_lang"] == "raw"]
|
||||
show(f"[{lang}] ...and not one field falls back to its raw key name. "
|
||||
f"Raw: {_raw}", not _raw)
|
||||
_nohelp = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if not f["help"]]
|
||||
show(f"[{lang}] ...and not one is left without the sentence that says "
|
||||
f"whether to touch it. Missing: {_nohelp}", not _nohelp)
|
||||
|
||||
# ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON
|
||||
# THE SAME DATA, and shape_probe §F is the measurement that makes it a claim
|
||||
# rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to
|
||||
# `ha-selector-object`, which labels them `fields[name].label || name`, and the
|
||||
# converter sets no label - so they render RAW there, however well UX-01
|
||||
# translated them. Here they are read out of the same `fields:` block and shown.
|
||||
_model_en = configui.field_model(ui, translations, "en")
|
||||
_roster = [f for g in _model_en["groups"] for f in g["fields"]
|
||||
if f["key"] == "inverters"][0]
|
||||
_sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]]
|
||||
show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is "
|
||||
"exactly what the Supervisor's Configuration tab renders raw on frontend "
|
||||
"20250925.1 (shape_probe §F)",
|
||||
all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7,
|
||||
json.dumps([(k, l) for k, l, _ in _sub]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==")
|
||||
# app/configui.py files each validator sentence under every option key it
|
||||
# NAMES, with word boundaries. Three of this schema's keys are suffixes of one
|
||||
# another, so a naive `in` test would file every per-unit refusal under the site
|
||||
# key as well. Run against the real key list rather than a hand-made one.
|
||||
_keys = sorted(defaults)
|
||||
_collide = [k for k in _keys if any(o != k and o.endswith("_" + k)
|
||||
for o in _keys)]
|
||||
show(f"config.yaml really does contain keys that are suffixes of other keys, "
|
||||
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
||||
_by, _un = configui.problems_by_field(
|
||||
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys)
|
||||
show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
||||
"`sign_disagree_w`",
|
||||
sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un,
|
||||
json.dumps(sorted(_by)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==")
|
||||
# ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR
|
||||
# THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is
|
||||
# imposed is one nobody re-examines, while a choice is something a decision
|
||||
# record has to justify. So it was RUN.
|
||||
#
|
||||
# The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`.
|
||||
# Delete `schema:` from config.yaml and there is nothing to render. Supervisor
|
||||
# ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab
|
||||
# really would go away.
|
||||
#
|
||||
# What goes away with it is the add-on. `AppOptions` DROPS every option that
|
||||
# the schema does not declare (one "Option 'x' does not exist in the schema"
|
||||
# warning per key, and nothing else), so `App.options` validates down to the
|
||||
# EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and
|
||||
# `load_options()` hands the controller nothing. The startup gate then refuses
|
||||
# it for having no actuator - exit 78 - and the ingress panel this card builds
|
||||
# is not running either, because the add-on serving it is not.
|
||||
#
|
||||
# So: removable, at the cost of every setting on the site and the add-on with
|
||||
# them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md
|
||||
# keeps the tab, and it is a much better reason than "the platform will not
|
||||
# let us".
|
||||
_noschema = {k: v for k, v in raw.items() if k != "schema"}
|
||||
try:
|
||||
_cfg2 = SCHEMA_APP_CONFIG(_noschema)
|
||||
_accepted, _detail = True, repr(_cfg2.get("schema"))
|
||||
except vol.Invalid as err:
|
||||
_cfg2, _accepted, _detail = None, False, str(err)
|
||||
show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its "
|
||||
"`schema_ui` is empty - so the Configuration tab is removable after all",
|
||||
_accepted and _cfg2.get("schema") == {}
|
||||
and UiOptions(None)(_cfg2["schema"]) == [], _detail)
|
||||
if _accepted:
|
||||
_v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"],
|
||||
_cfg2["name"], "local_x")))
|
||||
try:
|
||||
_out2 = _v2(copy.deepcopy(_cfg2["options"]))
|
||||
_kept = len(_out2)
|
||||
except vol.Invalid as err:
|
||||
_kept = f"refused: {err}"
|
||||
show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in "
|
||||
"the schema are discarded with one log warning each, so the add-on "
|
||||
"would start on an empty configuration, be refused by its own startup "
|
||||
"gate for having no actuator, and take the ingress panel down with "
|
||||
"it. That is why the tab stays",
|
||||
_kept == 0, f"options surviving an empty schema: {_kept} "
|
||||
f"of {len(defaults)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
||||
for title in FAILED:
|
||||
print(" - " + title)
|
||||
sys.exit(1)
|
||||
print("== all cases passed ==")
|
||||
@@ -3,8 +3,8 @@
|
||||
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 70
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 71
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
@@ -78,6 +78,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Battery power sign is reversed (optional)"
|
||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
||||
capacity_kwh:
|
||||
name: "Usable battery capacity (kWh) (optional)"
|
||||
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
|
||||
@@ -58,6 +58,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
||||
capacity_kwh:
|
||||
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
|
||||
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
|
||||
Reference in New Issue
Block a user