Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76639c7b2 | ||
|
|
57775d4e06 | ||
|
|
bb469163a6 | ||
|
|
477911afa1 | ||
|
|
21f22b393a | ||
|
|
6e7b23388c | ||
|
|
1ba35f9c99 | ||
|
|
0b653c35a6 | ||
|
|
d0633665c9 | ||
|
|
8e3734f5ea | ||
|
|
86bdf49332 | ||
|
|
fcaa72418d | ||
|
|
04b3330def | ||
|
|
fdb3b69397 | ||
|
|
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,219 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0
|
||||
|
||||
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
||||
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
|
||||
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
|
||||
that kind is true here, and it was checked rather than assumed:
|
||||
|
||||
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
|
||||
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
|
||||
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
|
||||
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
|
||||
flat single-inverter shape the reference install runs and an N=2 roster with
|
||||
no `capacity_kwh` on any entry: **0 problems each**.
|
||||
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
|
||||
an unparseable value, or the key stated on one roster entry and left empty on
|
||||
another. Neither can reach a config written before the key existed.
|
||||
|
||||
So: existing installs upgrade untouched, and the bump is minor because the
|
||||
option surface and the behaviour grew.
|
||||
|
||||
Contents, in merge order since 0.8.0 (`06661c5`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
|
||||
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
|
||||
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
|
||||
|
||||
**UX-02** builds the real configuration surface in the ingress panel, beside the
|
||||
Supervisor Configuration tab that cannot be removed. The prize is not styling:
|
||||
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
|
||||
cannot show*, and until now an operator met them as **exit 78 in a log**, after
|
||||
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
|
||||
names the **stored options blob** as authoritative, neither UI.
|
||||
|
||||
**OPT-09** makes the architecture's one free parameter real. `main.py` called
|
||||
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
|
||||
either pack could deliver; it now passes SoC- and capacity-aware weights.
|
||||
|
||||
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
|
||||
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
|
||||
to a small share puts a unit delivering **none of what it was asked** under the
|
||||
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
|
||||
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
||||
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
||||
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The
|
||||
closure is card SAFETY-43.
|
||||
|
||||
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
||||
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||
behind it, which is why this section exists:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.8.0 --options /w/live-options.json
|
||||
|
||||
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
|
||||
|
||||
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
|
||||
install's options were exported from the Supervisor (70 keys) and checked twice
|
||||
- the stored blob as-is, and the **effective post-upgrade config**, being those
|
||||
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
|
||||
against a rule-10 ceiling of 555.6 W.
|
||||
|
||||
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
|
||||
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
|
||||
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
|
||||
is the shape that becomes exit 78. It resolves to its `options:` default of
|
||||
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
|
||||
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
|
||||
against a real Supervisor image rather than assuming it. The second check above
|
||||
exists because "your saved settings are valid" and "what the box will actually
|
||||
run is valid" are different questions, and only the second one matters.
|
||||
|
||||
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
|
||||
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
|
||||
tool during PKG-09's review rather than by a person. `ha apps update` against an
|
||||
unchanged version installs nothing and reports success. PKG-10 carries the
|
||||
post-merge alarm so that noticing stops being a human's job.
|
||||
|
||||
## 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 |
|
||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||
|
||||
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
||||
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
||||
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
|
||||
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
|
||||
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
|
||||
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
|
||||
|
||||
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
|
||||
beside `sitesim.py`. It is there and not here on purpose: those two modules are
|
||||
that repo's, and restating them in this tree is the CI-08 coupling that has
|
||||
broken three times. The dependency runs one way instead - this repo's `control`,
|
||||
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
|
||||
whichever checkout is under test. Re-run it with:
|
||||
|
||||
```
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --blag
|
||||
```
|
||||
|
||||
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
|
||||
than adjusted to agree. What DID reproduce is the part the compatibility promise
|
||||
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
|
||||
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
|
||||
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
|
||||
non-monotone curve (this rebuild's is monotone, so that finding is now an
|
||||
argument and not a measurement), and the low-threshold rows, badly. The B-lag
|
||||
rows moved the other way - a hypothetical unit four times slower than A gives
|
||||
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
|
||||
study on a hypothesis about inverter B, which does not physically exist yet**,
|
||||
rather than as a measurement of it.
|
||||
|
||||
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
|
||||
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
|
||||
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
|
||||
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
|
||||
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
|
||||
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
|
||||
would report on ordinary saturation. The option is marked *derived, do not
|
||||
change*; raising the schema floor is left to its own card.
|
||||
|
||||
⚠️ **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.
|
||||
|
||||
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
|
||||
file, three assertions to a probe, and a decisions record; `config.yaml`,
|
||||
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
|
||||
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
|
||||
criteria that were never met:
|
||||
|
||||
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
|
||||
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
|
||||
unused optional configuration options* toggle can hide is **empty**, and
|
||||
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
|
||||
shows those unconditionally, so 59 is the floor even with `options:` emptied
|
||||
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
|
||||
12 hides that *are* expressible would be bought with the deep-merge: a key
|
||||
absent from `options:` has no default for the Supervisor to merge into a
|
||||
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
|
||||
that makes this release's upgrade safe rather than exit 78. The count was
|
||||
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
|
||||
written and never graded, which is how this passed review; it is now three
|
||||
assertions, and removing one key from `options:` turns two of them red.
|
||||
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
|
||||
is BUILT**, as `test_defaults_pin.py`. It discovers the six
|
||||
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
|
||||
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
|
||||
exactly today's **27 (module, key) pairs** so it can only shrink. It found
|
||||
one previously unreported divergence: `main.py` answers a missing
|
||||
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
|
||||
a Supervisor install, recorded rather than corrected. Its red direction runs
|
||||
on every invocation, and a literal planted in the real tree was shown red.
|
||||
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
|
||||
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
|
||||
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
|
||||
where it survived 21/21 green on 0.7.0. The attribution is corrected in
|
||||
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
|
||||
re-done.
|
||||
|
||||
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
|
||||
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
|
||||
those fields `fields[name].label || name` and the converter sets no `label` -
|
||||
and UX-02's ingress panel is the surface that can render them. A written
|
||||
decision, not a change.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
@@ -239,7 +453,11 @@ 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.** `test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3368231`
|
||||
**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`).
|
||||
|
||||
@@ -198,8 +198,12 @@ named**. The suppression window widens from `site target < 2 * min_w` = 1000 W t
|
||||
`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. `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.
|
||||
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
|
||||
|
||||
@@ -68,22 +68,92 @@ deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it
|
||||
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
|
||||
|
||||
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
|
||||
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
|
||||
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
|
||||
days. The paragraph this replaces was true — what the harness does *is* small
|
||||
enough to restate — and it was not enough: a restatement in prose leaves every
|
||||
axis it does not name to the next person's judgement, and §3.3 below now records
|
||||
how far that moves the number. **It moved. See §3.3's second table.**
|
||||
|
||||
It is committed in the **other** repository, `admin/goodwe`, as
|
||||
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
|
||||
harness it is built on. From clean checkouts of both repos:
|
||||
|
||||
```
|
||||
cd goodwe/sim
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
|
||||
```
|
||||
|
||||
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
|
||||
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
|
||||
|
||||
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
|
||||
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
|
||||
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
|
||||
that repo** and neither present in this one's CI image. Committed here it had
|
||||
two possible fates and both are the coupling CI-08 has been broken by three
|
||||
times: skipped for want of its imports — a green that establishes nothing — or
|
||||
the plant and the encoder **restated in this tree**, which is a second copy of
|
||||
something the other repo owns, free to drift, and drift in a fixture is
|
||||
invisible until the number it defends is wrong. There the dependency runs one
|
||||
way and stays one-directional: **this** repo's `control`, `arbiter` and
|
||||
`delivery` are imported live out of whichever checkout is under test
|
||||
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
|
||||
number on the next run instead of silently disagreeing with a fixture. That is
|
||||
the seam `sitesim.py` already stands on, which is the other reason the campaign
|
||||
is its neighbour and not a third arrangement.
|
||||
|
||||
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
|
||||
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
|
||||
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
|
||||
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
|
||||
checks out this repo for `two_writers` and accepts that boundary in writing. So
|
||||
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
|
||||
shape of `delinquent()` turns that step red rather than leaving a stale number
|
||||
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
|
||||
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
|
||||
the **rig** runner, which a container-free simulation has no business holding.
|
||||
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
|
||||
that is the block above.
|
||||
|
||||
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
|
||||
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
|
||||
everywhere is indistinguishable from a campaign whose detector was never wired
|
||||
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
|
||||
drives a genuinely **wedged** unit through the same loop and refuses to proceed
|
||||
unless the shipped `delinquent()` names it for longer than the shipped hold
|
||||
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
|
||||
that arm passes.
|
||||
|
||||
What it does, unchanged from the restatement it replaces: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
|
||||
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
|
||||
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
|
||||
put each share on its own wire through `Inverter.command()`, honouring a
|
||||
configurable **write stagger** so B's number can land up to four cycles after
|
||||
A's (the serialised write path); advance `Site.settle()` one control cycle; and
|
||||
record, per unit, the command standing *before* the write and that unit's 0x0518
|
||||
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
|
||||
take the **longest run of consecutive cycles** on which it names anybody — not a
|
||||
count of naming samples, because the window is cleared by any cycle that does
|
||||
not.
|
||||
|
||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||
command standing before the write" is the **controller's** standing command —
|
||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||
wire. A write that has been issued but has not landed yet is standing as far as
|
||||
this add-on is concerned, which is the whole of the write-stagger axis. Grading
|
||||
against the plant's latch instead reads a number no add-on has, and it makes
|
||||
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
|
||||
shows a standing command of 0 W, is never `asked`, and is never named. That is
|
||||
not a conservative harness, it is a harness that cannot fail — and the rebuild
|
||||
did exactly this on its first run, which is how the falsifiability arm earned
|
||||
its place above.
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||||
other repository and are not present in this one's CI image. What it does is
|
||||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||||
can land up to four cycles after A's (the serialised write path); advance
|
||||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||||
cycles** on which it names anybody — not a count of naming samples, because the
|
||||
window is cleared by any cycle that does not.
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
@@ -213,7 +283,9 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold:
|
||||
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
|
||||
committed — SUPERSEDED, and kept here because a record that quietly replaced its
|
||||
own numbers would be worth less than one that shows both:**
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
@@ -225,7 +297,100 @@ threshold:
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
Three findings worth stating rather than leaving in the table:
|
||||
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
|
||||
|
||||
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
|
||||
counts, same thresholds, the shipped `delinquent()`:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
|
||||
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
|
||||
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
|
||||
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
|
||||
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
|
||||
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
|
||||
|
||||
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
|
||||
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
|
||||
argmax is printed with the table on purpose (see below): a worst case sitting on
|
||||
an edge of the grid would be a number about the grid.
|
||||
|
||||
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
|
||||
argmax's other axes held fixed:**
|
||||
|
||||
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
|
||||
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
|
||||
0.10 -> 35.0 s
|
||||
|
||||
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
|
||||
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
|
||||
6) and `slew_w` (3rd of 5).
|
||||
|
||||
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
|
||||
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
|
||||
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
|
||||
it. A site whose two writes land in the same cycle does not produce this episode
|
||||
at all.
|
||||
|
||||
This does not move `delivery_hold_s`: 185 s must cover the worst case the
|
||||
modelled site can produce, and a stagger of four cycles is inside what a real
|
||||
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
|
||||
the plant or about the grid?" deserves the answer, and the answer is: **partly
|
||||
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
|
||||
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
|
||||
argmax was interior, and it is not.
|
||||
|
||||
**WHAT REPRODUCED, EXACTLY:**
|
||||
|
||||
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
|
||||
§3.2's algebra measured, and it is the claim the compatibility promise rests
|
||||
on. (This rebuild ran the control arm over all three families — 2640 runs —
|
||||
where the original ran 1890, i.e. the two families whose stimulus is not
|
||||
weighted. The weighted stimulus at a roster of one is the same stimulus with
|
||||
inert weights, and it too reports nothing at any threshold.)
|
||||
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
|
||||
proper-subset rule really does defeat the family that defeated SAFETY-30's
|
||||
site window.
|
||||
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
|
||||
|
||||
**WHAT DID NOT:**
|
||||
|
||||
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
|
||||
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
|
||||
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
|
||||
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
|
||||
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
|
||||
describes is real and is still the reason not to read either table as a
|
||||
sensitivity curve — lowering the threshold really can turn a proper subset
|
||||
into the whole roster and break the run — but **this grid does not exhibit
|
||||
it**, so the claim now rests on the mechanism alone and no longer on a
|
||||
measurement. That is a weaker claim than the one this record made, and it is
|
||||
written down as weaker.
|
||||
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
|
||||
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
|
||||
of how much of the original table was a property of axes nobody wrote down.
|
||||
|
||||
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
|
||||
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
|
||||
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
|
||||
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
|
||||
**not** fix the individual values, the run length, or the per-unit cap. The
|
||||
rebuild spread them over the admissible ranges and **prints every one of them
|
||||
above its own table**, so the caveat travels with the number instead of living
|
||||
in somebody's memory. The residual disagreement — one to two control cycles at
|
||||
the shipped threshold — is inside the sensitivity to that choice; the
|
||||
low-threshold rows are not, and are reported as not.
|
||||
|
||||
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
|
||||
correction made during the rebuild was made *against* the doc's number, not
|
||||
towards it, and it is recorded in §1: grading on the plant's latch rather than
|
||||
the controller's standing command produced a harness that could not name even a
|
||||
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
|
||||
|
||||
Findings 1-3 below are the original campaign's, kept with finding 3 now
|
||||
qualified by the re-run above:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
@@ -268,37 +433,77 @@ would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||||
reversals, 0.0 s on flapping).
|
||||
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
|
||||
said 30.0 s and it did not reproduce**). The worst continuous legitimate
|
||||
episode at the shipped threshold across all 2640 runs — weighted family, worst
|
||||
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
|
||||
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
|
||||
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
|
||||
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
|
||||
At 30.0 s it coincided with the firmware's veto below and a reader could not
|
||||
tell which of the two was doing the work. At 35.0 s the rig's number is on its
|
||||
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
|
||||
low**, and the schema's `int(30,900)` floor no longer sits above the worst
|
||||
legitimate episode. An operator who sets it to the schema minimum of 30 s is
|
||||
now **below** a measured legitimate episode and will get false reports; the
|
||||
option is marked *derived, do not change* and this is what that marking is
|
||||
for. Raising the schema floor to 35 is a change to a shipped option's
|
||||
contract and is left to its own card rather than smuggled in here.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||||
*different* second unit through the weighted family (750 runs each):
|
||||
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
|
||||
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
|
||||
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
|
||||
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
|
||||
False`, so the 35.0 s above is **two identical machines** and every row below
|
||||
is a *what-if*: what a slower second unit would cost this window. **No row
|
||||
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
|
||||
margin — which term the window has to be sized against — and that is what it
|
||||
is used for. Sweeping a *different* second unit through the weighted family
|
||||
(750 runs each). Second column is the original record, third is TEST-21's
|
||||
re-run with the committed harness (`--blag`):
|
||||
|
||||
| B lag | B ramp | worst legitimate | runs naming |
|
||||
|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||||
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
|
||||
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
|
||||
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
|
||||
|
||||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||||
binding term. So the number to carry margin against is B's lag, and a unit
|
||||
**four times slower than A** produces 90.0 s.
|
||||
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
|
||||
the original finding and it survives — with one correction: *"the RAMP does
|
||||
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
|
||||
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
|
||||
rather than no term. The shortfall lasts as long as the unit's first-order lag
|
||||
holds it more than `saturation_w` below its share; at these step sizes the
|
||||
ramp is rarely the binding one. So the number to carry margin against is still
|
||||
B's lag — and a hypothetical unit **four times slower than A** produces
|
||||
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
|
||||
row moved **in the margin's favour**, while the floor above moved against it.
|
||||
Neither was adjusted to suit the other.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||||
reader must not be able to think this was carried across — and not 150 or 200.
|
||||
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
|
||||
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
|
||||
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
|
||||
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
|
||||
that is SAFETY-02's site hold, and a reader must not be able to think this was
|
||||
carried across — and not 150 or 200.
|
||||
|
||||
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
|
||||
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
|
||||
four bounds still clear, none of them narrowly, and the *ordering* of the
|
||||
argument is unchanged: the rig's floor is the binding one, the quarter is the
|
||||
ceiling, and 185 sits between them with margin at both ends. So the value is
|
||||
**not changed** — a shipped safety constant is not moved because its margin
|
||||
went from 6.2× to 5.3× while every bound still holds, and moving it would
|
||||
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
|
||||
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
|
||||
s, because neither is what the harness produces.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,379 @@
|
||||
# UX-04 — what the Configuration tab renders, and the pin that was never built
|
||||
|
||||
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
|
||||
order [[UX-01]]'s unmet acceptance criteria are numbered:
|
||||
|
||||
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
|
||||
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
|
||||
the part of it that *was* reachable would be bought with the upgrade path.
|
||||
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
|
||||
rather than printed, so the withdrawal cannot be quietly reversed.
|
||||
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
|
||||
check, with its red direction executed on every run.
|
||||
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
|
||||
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
|
||||
attribution is corrected here; no work was re-done.
|
||||
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
|
||||
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
|
||||
become visible.
|
||||
|
||||
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
|
||||
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
|
||||
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
|
||||
`ha info` reports on the live install — and against the 26-file check suite in
|
||||
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
|
||||
192.168.2.158, posted options, or restarted anything.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiding: withdrawn, and why that is the *safe* answer
|
||||
|
||||
### 1.1 What AC 3 claimed
|
||||
|
||||
> "The visible count drops to ~15, with the rest behind HA's *show unused
|
||||
> optional configuration options* toggle."
|
||||
|
||||
### 1.2 What the tab actually renders
|
||||
|
||||
`config.yaml` declares **71 keys under `options:` and the same 71 under
|
||||
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
|
||||
therefore **empty**:
|
||||
|
||||
```
|
||||
hideable = set(schema) - set(options) -> [] (0 of 71)
|
||||
```
|
||||
|
||||
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
|
||||
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
|
||||
either way. AC 3 is not partially met; it is unstarted.
|
||||
|
||||
### 1.3 The rule that decides it, replayed rather than paraphrased
|
||||
|
||||
From the frontend bundle shipped **in this image**,
|
||||
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
|
||||
|
||||
```js
|
||||
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
|
||||
```
|
||||
|
||||
`opts` is the add-on's options **as Supervisor serves them** — the operator's
|
||||
stored blob after `options:` has been deep-merged into it. So a field is hidden
|
||||
only when it is **both** absent from `options:` **and** not `required`. The
|
||||
probe now runs that filter on this manifest rather than quoting it.
|
||||
|
||||
### 1.4 Reason one: the target was never reachable
|
||||
|
||||
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
|
||||
optional. The filter above shows a required field unconditionally. So the
|
||||
**floor** — what the tab renders with `options:` emptied *altogether*, which is
|
||||
the most hiding that is even expressible — is 59 fields.
|
||||
|
||||
| `options:` block | fields rendered |
|
||||
|---|---|
|
||||
| as shipped (71 keys) | **71** |
|
||||
| emptied entirely (0 keys) | **59** |
|
||||
| AC 3's target | ~15 |
|
||||
|
||||
AC 3 asked for a number roughly four times below the floor. No arrangement of
|
||||
`options:` reaches it. The criterion could not have been met by any
|
||||
implementation; it was mis-specified, and the honest disposal of a
|
||||
mis-specified criterion is withdrawal, not a partial gesture toward it.
|
||||
|
||||
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
|
||||
|
||||
Moving a key out of `options:` is not free. `App.options` is
|
||||
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
|
||||
from `options:` has no default for the Supervisor to merge**.
|
||||
|
||||
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
|
||||
the pre-deploy check run against this release on 2026-09-07:
|
||||
|
||||
> The reference install's stored blob has **70 keys, not 71**.
|
||||
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
|
||||
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
|
||||
> `?`, which is the shape that becomes **exit 78**. It resolves to its
|
||||
> `options:` default of **185** because the Supervisor deep-merges.
|
||||
|
||||
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
|
||||
is, precisely, the edit that removes a key's merge default — the thing standing
|
||||
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
|
||||
fewer rows on a form, on a site where the add-on is holding a real house's grid
|
||||
exchange at zero.
|
||||
|
||||
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
|
||||
|
||||
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
|
||||
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
|
||||
with an in-code literal. Today those literals are unreachable on a Supervisor
|
||||
install, because the deep-merge guarantees every `options:` key is present in
|
||||
the served blob. Remove keys from `options:` to hide them and those literals
|
||||
stop being dead code: they become **the actual source of the defaults on a live
|
||||
install** — the CFG-03 defect the same card's AC 4 exists to police, made
|
||||
load-bearing by the card's own AC 3.
|
||||
|
||||
### 1.7 What now grades this
|
||||
|
||||
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
|
||||
it was written — *"the Configuration tab renders 71 fields"* — and the probe
|
||||
exited 0 through UX-01's whole review while displaying the number that
|
||||
contradicted the criterion it was evidence for. **A measurement that is
|
||||
displayed but not asserted is how this passed review.** Three assertions
|
||||
replace the bare print:
|
||||
|
||||
```
|
||||
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
|
||||
- the only set HA's toggle can hide - is EMPTY. Hideable: []
|
||||
PASS ...so replaying the frontend's own _filteredSchema on the served options
|
||||
hides NOTHING: 71 of 71 fields render
|
||||
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
|
||||
expressible - the tab still renders 59 required fields. THAT is the
|
||||
floor, and AC 3 asked for ~15.
|
||||
```
|
||||
|
||||
Every number in them is computed from the manifest and from Supervisor's own
|
||||
`required` flag; none is typed. **The red direction was run**: deleting one key
|
||||
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
|
||||
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
|
||||
exits 1. The counterfactual is isolated: one key varied, nothing else.
|
||||
|
||||
So if a future editor empties `options:` to shorten the form, this goes red and
|
||||
somebody has to read §1.5 before merging.
|
||||
|
||||
---
|
||||
|
||||
## 2. The defaults pin: `test_defaults_pin.py`
|
||||
|
||||
### 2.1 What AC 4 asked for and what shipped
|
||||
|
||||
AC 4 has three clauses.
|
||||
|
||||
| clause | state before UX-04 |
|
||||
|---|---|
|
||||
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
|
||||
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
|
||||
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
|
||||
|
||||
The only literal scan in the repository before this card is
|
||||
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
|
||||
keys across three named files. It is a grep, it predates UX-01, and it was not
|
||||
written for this.
|
||||
|
||||
### 2.2 What an "in-code literal fallback" is, structurally
|
||||
|
||||
A call in `app/` taking a string literal that names a `schema:` key, with a
|
||||
constant in the next positional slot:
|
||||
|
||||
```python
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
```
|
||||
|
||||
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
|
||||
are found, and the same text inside a comment or docstring is not. Two
|
||||
exclusions, both structural and both argued at the site:
|
||||
|
||||
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
|
||||
option off a blob. Excluded by callee name.
|
||||
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
|
||||
stated", and all six such sites in `validate.py` normalise it immediately —
|
||||
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
|
||||
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
|
||||
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
|
||||
defaults with real meanings.
|
||||
|
||||
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
|
||||
|
||||
```
|
||||
2. the mirrors, DISCOVERED from the tree rather than listed here
|
||||
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
|
||||
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
|
||||
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
|
||||
30 mirrored key(s) of 71 in `schema:`
|
||||
3. the AST scan
|
||||
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
|
||||
34 over unmirrored ones
|
||||
4. the CFG-03 ledger
|
||||
27 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
```
|
||||
|
||||
Four properties, none of them vacuous:
|
||||
|
||||
1. **The mirrors are discovered, not listed.** Six suites already pin their own
|
||||
table to `config.yaml` — and every one of those pins names its table by
|
||||
hand, so a *seventh* table added tomorrow would be pinned by nobody and
|
||||
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
|
||||
assignments and grades whatever it finds against `config.yaml`. That is the
|
||||
property the six hand-written pins cannot have.
|
||||
2. **No literal survives for a key a mirror already answers for.** 30 keys,
|
||||
0 violations.
|
||||
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
|
||||
count goes green again the moment somebody deletes one site and adds
|
||||
another. Line numbers are deliberately *not* in the key: a ledger that goes
|
||||
red on an unrelated reformat is a ledger people learn to re-bless without
|
||||
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
|
||||
here, because that is behaviour change across `main.py`, `p1.py`,
|
||||
`inverter.py` and `validate.py` that no single card should make in passing.
|
||||
The set can now only shrink.
|
||||
4. **Every remaining literal is graded against `config.yaml`** — the half that
|
||||
catches CFG-04's actual failure mode, two copies of one number and one moves.
|
||||
|
||||
### 2.4 One divergence found, and it is new
|
||||
|
||||
```
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
```
|
||||
|
||||
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
|
||||
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
|
||||
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
|
||||
reach an operator**: on a Supervisor install the deep-merge guarantees the key
|
||||
is present, and the only paths that hand over a partial dict are
|
||||
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
|
||||
as the single permitted divergence, with the reason, rather than corrected —
|
||||
changing it alters the control loop's target and belongs to a card that grades
|
||||
the loop. A *second* divergence goes red.
|
||||
|
||||
### 2.5 ⭐ The red direction, shown rather than claimed
|
||||
|
||||
A check that has never been shown failing is not a check — which is exactly
|
||||
what clause one of this AC was. Two things follow from that:
|
||||
|
||||
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
|
||||
synthetic module containing a deliberate violation of each rule and *requires*
|
||||
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
|
||||
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
|
||||
`SCHEMA` that comes back empty — stops working here too, and the total site
|
||||
count is additionally graded against a floor.
|
||||
|
||||
**(b) A literal was planted in the real tree and the check went red.** Planted
|
||||
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
|
||||
key:
|
||||
|
||||
```
|
||||
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
|
||||
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
|
||||
ok the scanner is not blind: it found 35 sites (floor 20)
|
||||
main.py:290 get('maintenance_charge_w', 2000)
|
||||
FAIL no literal fallback survives for any of the 30 keys a mirror already
|
||||
answers for - those sites must index the mirror
|
||||
|
||||
1 FAILED (exit 1)
|
||||
```
|
||||
|
||||
And part 4's two directions, planted the same way with
|
||||
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
|
||||
|
||||
```
|
||||
4. the CFG-03 ledger
|
||||
28 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
|
||||
main.py p1_host: code 'x' vs config.yaml ''
|
||||
main.py slew_w: code 999 vs config.yaml 0
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
FAIL every remaining literal still AGREES with config.yaml, except the 1
|
||||
divergence(s) recorded above it
|
||||
|
||||
(exit 1)
|
||||
```
|
||||
|
||||
The second of those is the CFG-04 defect class itself: a literal that has
|
||||
drifted away from `config.yaml` while the file still says something else. Both
|
||||
plants were removed; the tree is byte-identical to `6e7b233` apart from this
|
||||
card's own files.
|
||||
|
||||
---
|
||||
|
||||
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
|
||||
|
||||
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
|
||||
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
|
||||
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
|
||||
survives: 21 files, 0 RED.
|
||||
|
||||
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
|
||||
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
|
||||
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
|
||||
|
||||
| 0.7.0 | `release/1.0` @ `6e7b233` |
|
||||
|---|---|
|
||||
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
|
||||
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
|
||||
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
|
||||
|
||||
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
|
||||
|
||||
```
|
||||
1 FAILED: ⚠️ 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
|
||||
```
|
||||
|
||||
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
|
||||
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
|
||||
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
|
||||
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
|
||||
here; the mutation was run once, on a throwaway copy, to confirm it.
|
||||
|
||||
---
|
||||
|
||||
## 4. The 8 per-entry translations: they stay
|
||||
|
||||
### 4.1 The measurement, and it is negative
|
||||
|
||||
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
|
||||
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
|
||||
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
|
||||
their coverage. **They do not reach the screen**, and this is not a defect in
|
||||
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
|
||||
the frontend's `_convertSchemaElement` sends exactly that case to a selector
|
||||
rather than to a nested `ha-form`:
|
||||
|
||||
```js
|
||||
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
|
||||
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
|
||||
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
|
||||
```
|
||||
|
||||
and `ha-selector-object` labels those fields `fields[name].label || name`, while
|
||||
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
|
||||
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
|
||||
probe already asserts this, negatively and by name.
|
||||
|
||||
### 4.2 Decision: keep them. Four reasons.
|
||||
|
||||
1. **The Supervisor tab cannot be made to render them.** The gap is in
|
||||
somebody else's converter, not in this repository. There is no change here
|
||||
that fixes it; deleting the translations does not fix it either.
|
||||
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
|
||||
the roster itself rather than handing it to `ha-selector-object`. The
|
||||
`fields:` block is the shape Supervisor's own schema defines for exactly
|
||||
this, and it is what a surface that wires `label` through consumes.
|
||||
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
|
||||
in files the Supervisor already parses, and the parse is graded green.
|
||||
4. **Deleting them would remove the only thing that catches a per-entry key
|
||||
added without a translation.** `test_translations.py` grades top-level and
|
||||
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
|
||||
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
|
||||
joined the roster with no label and nothing to say so.
|
||||
|
||||
This is a written decision, not a change. The tree is unmodified in
|
||||
`translations/`.
|
||||
|
||||
### 4.3 Not re-filed
|
||||
|
||||
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
|
||||
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
|
||||
already filed, and it is not re-filed or fixed here. It is named only so a
|
||||
reader of §4.1 knows why the *other* probe is the one quoted.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card did not do
|
||||
|
||||
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
|
||||
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
|
||||
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
|
||||
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
|
||||
* It did not touch `translations/`. See §4.2.
|
||||
* It did not re-do CFG-04. See §3.
|
||||
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
|
||||
`sim/`. Both probe runs were throwaway containers over a read-only mount.
|
||||
+115
-2
@@ -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`,
|
||||
@@ -47,12 +110,13 @@ so Home Assistant's *Configuration* tab renders human labels and a description
|
||||
for every option instead of the raw option keys. What that does and does not buy
|
||||
you is worth stating plainly, because it was **measured against the Supervisor
|
||||
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
|
||||
used only to print) the
|
||||
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 |
|
||||
@@ -1497,9 +1561,58 @@ 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 (six merges behind an unbumped
|
||||
version) and PKG-07 (six again). 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.
|
||||
|
||||
@@ -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))
|
||||
@@ -264,36 +264,66 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
# such family, so reading its numbers across would have understated this.
|
||||
#
|
||||
# Every run was executed a second time at a roster of ONE, on the same
|
||||
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
|
||||
# stimulus, as the control.
|
||||
#
|
||||
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
|
||||
# harness that produced the original table was never committed, which is
|
||||
# PROC-01's shape; it is committed now, in the OTHER repository, as
|
||||
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
|
||||
# `frame_check`, which that repo owns - restating them here would be CI-08's
|
||||
# coupling, and CI-08 has broken three times). Re-derive it with:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (1890 runs)
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (2640 runs)
|
||||
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
|
||||
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
|
||||
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
|
||||
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
|
||||
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
|
||||
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
|
||||
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
|
||||
#
|
||||
# * is the shipped threshold, `Tuning.saturation_w`.
|
||||
#
|
||||
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
|
||||
# threshold. The compatibility promise is measured, not argued.
|
||||
# (b) The FLAPPING column is zero everywhere too - and that is the family
|
||||
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
|
||||
# window inside the billing quarter surviving it). It cannot touch this
|
||||
# check, because a common stimulus makes BOTH units short at once and
|
||||
# the subset stops being proper.
|
||||
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
|
||||
# and that is a property of the reduction, not noise: a lower threshold
|
||||
# puts MORE units into the short set, which can turn a proper subset
|
||||
# into the whole roster and BREAK the run. Do not read it as a
|
||||
# sensitivity curve.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
|
||||
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
|
||||
# is the part that reproduced most cleanly.
|
||||
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
|
||||
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
|
||||
# N=1, no window inside the billing quarter surviving it). It cannot
|
||||
# touch this check, because a common stimulus makes BOTH units short at
|
||||
# once and the subset stops being proper.
|
||||
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
|
||||
# at 100 W - and that was read as a property of the reduction: a lower
|
||||
# threshold puts MORE units into the short set, which can turn a proper
|
||||
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
|
||||
# IS MONOTONE. The mechanism is real and is still the reason not to read
|
||||
# either table as a sensitivity curve, but it is no longer something
|
||||
# these numbers demonstrate - it is an argument now, not a measurement.
|
||||
#
|
||||
# THE BOUNDS THE VALUE ANSWERS TO:
|
||||
#
|
||||
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs.
|
||||
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
|
||||
# and it did not reproduce), the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
|
||||
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
|
||||
# below and a reader could not tell which was doing the work; at 35.0 s it
|
||||
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
|
||||
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
|
||||
# legitimate episode. That is what "derived, do not change" is for.
|
||||
#
|
||||
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its share for less than the firmware's own veto window is a
|
||||
@@ -302,26 +332,47 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
# second, louder alarm about a state already being handled - the
|
||||
# SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
|
||||
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
|
||||
# `measured=False`), so the 30.0 s above is two identical machines.
|
||||
# Sweeping a DIFFERENT second unit through the weighted family: the worst
|
||||
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
|
||||
# four times it. That is the sensitivity the margin has to carry until
|
||||
# SPIKE-04 measures the real unit.
|
||||
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
|
||||
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
|
||||
# to say "MEASURED rather than GUESSED", which overstated it. B is not
|
||||
# installed (~2026-09-12). The campaign's B is A COPIED
|
||||
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
|
||||
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
|
||||
# LAG. Sweeping a DIFFERENT second unit through the weighted family
|
||||
# (2026-09-06 -> TEST-21 re-run):
|
||||
#
|
||||
# B lag / ramp original re-run
|
||||
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
|
||||
# 20.0 s / 800 W/s 45.0 s 45.0 s
|
||||
# 10.0 s / 400 W/s 30.0 s 35.0 s
|
||||
# 20.0 s / 400 W/s 45.0 s 50.0 s
|
||||
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
|
||||
#
|
||||
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
|
||||
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
|
||||
# and +5 s at twice it - so the ramp is second-order, not absent. That is
|
||||
# the sensitivity the margin has to carry until SPIKE-04 measures the real
|
||||
# unit.
|
||||
#
|
||||
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
||||
# A site running short of one unit's share must be reported well inside
|
||||
# one.
|
||||
#
|
||||
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
|
||||
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
|
||||
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
|
||||
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
|
||||
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
|
||||
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
|
||||
# the firmware's own veto window (30 s), and 4.9x inside the quarter
|
||||
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# is a different check on a different quantity, so a reader must not be
|
||||
# able to think it was carried across - and not 150 or 200.
|
||||
#
|
||||
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
|
||||
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
|
||||
# them narrowly, and the ordering of the argument is unchanged. A shipped
|
||||
# safety constant is not moved because a margin went from 6.2x to 5.3x while
|
||||
# every bound holds - moving it would re-open SAFETY-41's four-attempt
|
||||
# false-trip regime for nothing. What changed is the CLAIM, not the value.
|
||||
#
|
||||
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
|
||||
# short by one unit's share before anybody is told. Against a fault that ran
|
||||
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
|
||||
|
||||
@@ -528,7 +528,7 @@ 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
|
||||
@@ -616,6 +616,35 @@ 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.
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
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
|
||||
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.8.0"
|
||||
version: "0.9.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -560,17 +560,31 @@ options:
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
||||
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
||||
# whole range this schema accepts - across load reversals, a flapping load and
|
||||
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
|
||||
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
|
||||
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
|
||||
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
|
||||
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
|
||||
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
|
||||
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
|
||||
# in every one-inverter run). 185 s is 5.29x that.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
|
||||
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
|
||||
# DIFFERENT second unit through that campaign: the worst legitimate episode is
|
||||
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
|
||||
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
|
||||
# 2.06x even the four-times-slower case.
|
||||
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
|
||||
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
|
||||
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
|
||||
# which that repo owns. From clean checkouts of both, side by side:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
|
||||
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
|
||||
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
|
||||
# tables and what did and did not reproduce.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
|
||||
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
|
||||
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
|
||||
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
|
||||
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
|
||||
# A MEASUREMENT OF INVERTER B.
|
||||
#
|
||||
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
|
||||
`python3 -B test_defaults_pin.py`
|
||||
|
||||
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
|
||||
`options:` has exactly one defaults-table entry ... the table changed goes red
|
||||
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
|
||||
check, not a grep."
|
||||
|
||||
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
|
||||
71 keys, so "every schema key absent from options" quantifies over the empty set
|
||||
and is true of any tree, including one with no defaults at all. Clause two -
|
||||
"the table changed goes red" - does work, six times over, once per mirror in its
|
||||
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
|
||||
literal scan in this repository before this file is
|
||||
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
|
||||
grep, is restricted to eight `maintenance_*` keys and three named files, and
|
||||
predates UX-01.
|
||||
|
||||
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
|
||||
clause one's mistake. Every part below states the size of the set it quantifies
|
||||
over and grades that size against a floor derived from the tree, and part 5
|
||||
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
|
||||
the red direction is exercised on every run rather than attested in a commit
|
||||
message. MUTATION.md habit 4.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
|
||||
the definition rather than guess it. It is a call in `app/` that takes a STRING
|
||||
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
|
||||
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
|
||||
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
|
||||
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
|
||||
a `max_w` inside a comment or a docstring is not.
|
||||
|
||||
TWO EXCLUSIONS, both structural and both narrow:
|
||||
|
||||
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
|
||||
off the operator's blob. Its third argument is an absent-attribute guard and
|
||||
has no relationship to config.yaml. Excluded by function name.
|
||||
|
||||
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
|
||||
not stated", and every one of the six sites in `validate.py` immediately
|
||||
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
|
||||
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
|
||||
rewriting those six to index a mirror would replace a sentence that reads
|
||||
"absent" with one that reads "the shipped default" at sites where absent is
|
||||
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
|
||||
`integrator_max_w: 0` are real shipped defaults with real meanings.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
|
||||
|
||||
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
|
||||
CFG-03's open ticket - option defaults living in four places - and it is a body
|
||||
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
|
||||
that no single card should make in passing. What this file does instead is
|
||||
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
|
||||
on the commit that introduces it and the ledger can only ever shrink. A backlog
|
||||
that cannot grow is a backlog being paid down; one that is merely written in a
|
||||
comment is the state UX-01 left.
|
||||
|
||||
MUTATION.md is the method for the red-direction evidence in part 5, and
|
||||
`tools/mutation_run.sh` is the harness.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
APP = HERE / "app"
|
||||
|
||||
FAILED = 0
|
||||
|
||||
|
||||
def check(what, ok):
|
||||
global FAILED
|
||||
print((" ok " if ok else " FAIL ") + what)
|
||||
if not ok:
|
||||
FAILED += 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# config.yaml, parsed by hand.
|
||||
#
|
||||
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
|
||||
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
|
||||
# check that skips on ImportError is not a check. `test_maintenance.py` and
|
||||
# `test_price.py` each carry their own two-line reader for the same reason, and
|
||||
# this is the third - which is itself an instance of the defect this file
|
||||
# grades, one level up. It is written down rather than tidied away because
|
||||
# tidying it into a shared helper is a change to two suites that are green.
|
||||
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
|
||||
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""One `options:` value, as the yaml module this image lacks would read it."""
|
||||
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
if raw == "[]":
|
||||
return []
|
||||
for cast in (int, float):
|
||||
try:
|
||||
return cast(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def _block_keys(block):
|
||||
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
|
||||
|
||||
|
||||
SHIPPED = {m.group(1): _scalar(m.group(2))
|
||||
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
|
||||
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
|
||||
|
||||
print("UX-04 - the defaults pin\n")
|
||||
print("1. the surface this file quantifies over")
|
||||
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
|
||||
f"under `schema:`")
|
||||
# The floor, for part 1's own reason: a reader that returned {} would make every
|
||||
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
|
||||
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
|
||||
# inside the real size does the job, and this one cannot be reached by accident.
|
||||
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
|
||||
len(SHIPPED) >= 60)
|
||||
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
|
||||
len(SCHEMA) >= 60)
|
||||
check("every shipped default names a schema key, so a mirror pinned to one is "
|
||||
"pinned to the other",
|
||||
sorted(set(SHIPPED) - SCHEMA) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
||||
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
|
||||
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
|
||||
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
|
||||
# table added tomorrow is pinned by nobody and nothing says so. This walks
|
||||
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
||||
# grades whatever it finds, so the seventh is covered on the commit that
|
||||
# introduces it. That is the property the six hand-written pins cannot have.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
||||
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
||||
for name in MODULES}
|
||||
|
||||
MIRROR = {} # schema key -> (module, table name, value)
|
||||
TABLES = []
|
||||
for name in MODULES:
|
||||
for node in TREES[name].body:
|
||||
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
|
||||
continue
|
||||
table = node.targets[0].id
|
||||
TABLES.append((name, table))
|
||||
for k, v in zip(node.value.keys, node.value.values):
|
||||
# A literal key with a literal value is the only shape a mirror
|
||||
# has; anything else is not a table this file can grade, and
|
||||
# saying so out loud beats skipping it silently.
|
||||
MIRROR[k.value] = (name, table, ast.literal_eval(v))
|
||||
|
||||
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
|
||||
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
|
||||
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
|
||||
len(TABLES) >= 6)
|
||||
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
|
||||
check(f"every mirrored key is a real schema key {_unschemad or ''}",
|
||||
_unschemad == [])
|
||||
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
|
||||
check(f"...and is shipped in the options block {_unshipped or ''}",
|
||||
_unshipped == [])
|
||||
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
|
||||
if k in SHIPPED and v != SHIPPED[k])
|
||||
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
|
||||
f"suite names by hand - goes red on drift {_drift or ''}",
|
||||
_drift == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the AST scan: no module carries its own literal fallback for a "
|
||||
"MIRRORED schema default")
|
||||
|
||||
|
||||
def literal_fallbacks(tree, name):
|
||||
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
|
||||
|
||||
Yields (module, lineno, callee, key, value). See the header for the two
|
||||
exclusions and the argument for each.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
|
||||
if callee == "getattr":
|
||||
continue
|
||||
for i, arg in enumerate(node.args[:-1]):
|
||||
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
||||
and arg.value in SCHEMA):
|
||||
continue
|
||||
default = node.args[i + 1]
|
||||
if not isinstance(default, ast.Constant):
|
||||
continue
|
||||
if default.value is None or default.value == "":
|
||||
continue
|
||||
yield (name, node.lineno, callee, arg.value, default.value)
|
||||
|
||||
|
||||
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
|
||||
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
|
||||
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
|
||||
|
||||
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
|
||||
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
|
||||
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
|
||||
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
|
||||
# rotting into clause one. A scanner that silently stopped matching - a renamed
|
||||
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
|
||||
# sites and every check below would pass. So the total is graded against a floor
|
||||
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
|
||||
# scanner a source it MUST flag.
|
||||
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
|
||||
len(ALL_SITES) >= 20)
|
||||
for site in sorted(MIRRORED_SITES):
|
||||
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
|
||||
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
|
||||
f"already answers for - those sites must index the mirror",
|
||||
MIRRORED_SITES == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
|
||||
"frozen at exactly today's set")
|
||||
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
|
||||
# somebody deletes one site and adds another, which is the same-size drift this
|
||||
# repository keeps finding. The pair (module, key) is stable under every edit
|
||||
# that is not the one being guarded against: reformatting, moving the call
|
||||
# within the file, renaming the local `opts`. It changes only when a module
|
||||
# starts or stops carrying a literal for a key - which is exactly the event.
|
||||
#
|
||||
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
|
||||
# unrelated edit shifts a line is a ledger people learn to re-bless without
|
||||
# reading, and a ledger re-blessed without reading grades nothing.
|
||||
LEDGER = {
|
||||
("inverter.py", "max_w"),
|
||||
("main.py", "auto_start"),
|
||||
("main.py", "cycle_floor_s"),
|
||||
("main.py", "deadband_w"),
|
||||
("main.py", "estop_fitted"),
|
||||
("main.py", "gain"),
|
||||
("main.py", "heartbeat_s"),
|
||||
("main.py", "log_level"),
|
||||
("main.py", "meter_freeze_s"),
|
||||
("main.py", "meter_max_age_s"),
|
||||
("main.py", "meter_phases"),
|
||||
("main.py", "peak_cap_w"),
|
||||
("main.py", "saturation_cycles"),
|
||||
("main.py", "saturation_w"),
|
||||
("main.py", "sign_disagree_s"),
|
||||
("main.py", "sign_disagree_w"),
|
||||
("main.py", "slew_w"),
|
||||
("main.py", "stale_input_s"),
|
||||
("main.py", "step_w"),
|
||||
("main.py", "target_grid_w"),
|
||||
("p1.py", "meter_poll_s"),
|
||||
("p1.py", "meter_source"),
|
||||
("validate.py", "max_w"),
|
||||
("validate.py", "meter_max_age_s"),
|
||||
("validate.py", "meter_phases"),
|
||||
("validate.py", "sign_disagree_w"),
|
||||
("validate.py", "slew_w"),
|
||||
}
|
||||
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
|
||||
_new = sorted(_found - LEDGER)
|
||||
_gone = sorted(LEDGER - _found)
|
||||
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
|
||||
f"mirror answers for")
|
||||
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
|
||||
_new == [])
|
||||
check(f"...and none has been paid off without the ledger being shortened - "
|
||||
f"shrink LEDGER when it has {_gone or ''}",
|
||||
_gone == [])
|
||||
|
||||
# --- and every one of them is graded AGAINST config.yaml, which is the half
|
||||
# that catches CFG-04's actual failure: two copies of one number, one moves.
|
||||
DIVERGENT = {
|
||||
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
|
||||
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
|
||||
# and it is a real divergence, not a sentinel: a dict handed to
|
||||
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
|
||||
# shipped -10 W bias into import. On a Supervisor install the key is always
|
||||
# present - the deep-merge of `options:` guarantees it - so this cannot
|
||||
# reach an operator; the paths that CAN hand over a partial dict are
|
||||
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
|
||||
# than corrected because changing it is a behaviour change to the control
|
||||
# loop's target, which belongs to a card that grades the loop.
|
||||
("main.py", "target_grid_w"): (0, -10),
|
||||
}
|
||||
_diverge = {}
|
||||
for mod, line, callee, key, value in UNMIRRORED_SITES:
|
||||
want = SHIPPED.get(key)
|
||||
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
|
||||
if not same:
|
||||
_diverge[(mod, key)] = (value, want)
|
||||
for pair, (got, want) in sorted(_diverge.items()):
|
||||
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
|
||||
check(f"every remaining literal still AGREES with config.yaml, except the "
|
||||
f"{len(DIVERGENT)} divergence(s) recorded above it",
|
||||
_diverge == DIVERGENT)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. the red direction, RUN on every invocation")
|
||||
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
|
||||
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
|
||||
# while grading nothing. So the scanner is fed a source that is a deliberate
|
||||
# violation of each rule and is REQUIRED to flag it - in-process, against the
|
||||
# same `literal_fallbacks()` the parts above call, so a scanner that stops
|
||||
# working stops working here too.
|
||||
_PLANTED = '''
|
||||
def build(opts):
|
||||
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
|
||||
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
|
||||
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
|
||||
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
|
||||
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
|
||||
return a, b, c, d, e
|
||||
'''
|
||||
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
|
||||
_keys = sorted(h[3] for h in _hits)
|
||||
for h in _hits:
|
||||
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
|
||||
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
|
||||
"direction, exercised",
|
||||
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
|
||||
check("a planted literal for an unmirrored key is caught too - part 4's",
|
||||
"cycle_floor_s" in _keys)
|
||||
check('...and the two documented exclusions still hold: the "" sentinel and '
|
||||
"getattr are not reported",
|
||||
"static_profile" not in _keys and _keys.count("max_w") == 0)
|
||||
check(f"exactly the three intended sites, no more {_keys}",
|
||||
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
|
||||
|
||||
# ...and the mirror comparison's red direction, likewise executed rather than
|
||||
# claimed: part 2 goes red when a table and config.yaml disagree by one.
|
||||
_drifted = dict(MIRROR)
|
||||
_pick = sorted(MIRROR)[0]
|
||||
_mod, _tab, _val = _drifted[_pick]
|
||||
_drifted[_pick] = (_mod, _tab, "###drifted###")
|
||||
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
|
||||
f"{_pick!r})",
|
||||
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
|
||||
|
||||
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"{FAILED} FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -629,9 +629,11 @@ print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH
|
||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
||||
#
|
||||
# ⚠️ 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.
|
||||
#
|
||||
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
|
||||
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
|
||||
# a design change; it is filed as its own card. What this part does is GRADE THE
|
||||
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
|
||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
||||
#
|
||||
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
||||
@@ -747,7 +749,11 @@ check("...and the arithmetic behind it, stated as a window rather than as one "
|
||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
||||
"6500 W of a 9600 W site. That is the regression, and it is a property "
|
||||
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
|
||||
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
|
||||
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
|
||||
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
|
||||
"That is the regression, and it is a property "
|
||||
"of the threshold being absolute rather than of any number chosen here",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -248,9 +248,15 @@ 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 8 per-entry keys came back out of each (OPT-09 added the "
|
||||
"eighth, `capacity_kwh`), 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"]) == 8
|
||||
for lg in LANGS))
|
||||
@@ -356,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 "
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""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 twelve 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()
|
||||
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
|
||||
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
|
||||
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
|
||||
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
|
||||
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
|
||||
# A real repository edits the line IN PLACE, the count never moves again, and
|
||||
# the lookup silently returns the commit that first added it.
|
||||
#
|
||||
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
|
||||
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
|
||||
# bumped tree was reported as 87 commits behind an unbumped version - with
|
||||
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
|
||||
# cried wolf on every run, and none of the fixtures above could see it, because
|
||||
# a test that builds its own one-bump history is a test asserting against a
|
||||
# structure it built itself. MUTATION.md habit 4, in the file written to
|
||||
# prevent a recurrence.
|
||||
from predeploy_check import last_version_change # noqa: E402
|
||||
|
||||
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
|
||||
tmpdirs.append(root4)
|
||||
addon4 = os.path.join(root4, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon4, "app"))
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
|
||||
write(addon4, "app/control.py", "X = 1\n")
|
||||
git(root4, "init", "-q", ".")
|
||||
git(root4, "add", "-A")
|
||||
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
|
||||
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
write(addon4, "app/control.py", "X = 2\n")
|
||||
git(root4, "commit", "-qam", "runtime change between bumps")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.6.0")
|
||||
write(addon4, "app/control.py", "X = 3\n")
|
||||
git(root4, "commit", "-qam", "another runtime change")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.7.0")
|
||||
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
|
||||
"version line does not change how many times the string occurs, so a "
|
||||
"pickaxe search returns the commit that introduced it and every bump "
|
||||
"after that is invisible",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the commit that introduced the line",
|
||||
last_version_change(root4) != _first)
|
||||
|
||||
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("a tree bumped with nothing after it clears, even with bumps in history",
|
||||
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
|
||||
|
||||
write(addon4, "app/control.py", "X = 4\n")
|
||||
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
|
||||
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
|
||||
check("...naming only the commit since the LAST bump, not since the first",
|
||||
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
|
||||
|
||||
|
||||
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
|
||||
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
|
||||
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
|
||||
# above - because a linear history of real bumps never produces a commit that
|
||||
# touches the version LINE without changing the version VALUE. So the docstring's
|
||||
# central claim ("the VALUE is compared, and that is the only thing that answers
|
||||
# it") was not pinned by anything, and `-G` alone would have shipped as if it
|
||||
# were the fix. That is the third distinct shape of habit 4 in this one file.
|
||||
#
|
||||
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
|
||||
# matches it, and the value comparison does not.
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
|
||||
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
|
||||
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
|
||||
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
|
||||
"only comparing the VALUE against the parent's answers the question the "
|
||||
"tool is actually asking",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the cosmetic commit",
|
||||
last_version_change(root4) != _cosmetic)
|
||||
|
||||
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
|
||||
"still counted as something unshipped - conservative, and correct: the "
|
||||
"Supervisor rebuilds on the version string alone, so a changed file "
|
||||
"behind an unchanged version is a no-op whatever changed in it",
|
||||
r8.returncode == 1 and "COSMETIC" in r8.stdout)
|
||||
|
||||
|
||||
print()
|
||||
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
|
||||
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
|
||||
# them could ever meet the failure that actually happened: the documented
|
||||
# invocation runs the container as uid 0 against a checkout owned by the
|
||||
# operator, git refuses with "detected dubious ownership", `_git` returns
|
||||
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
|
||||
# never a false clearance - but the check this file exists for had never once
|
||||
# run in its documented form, and nothing here could see that.
|
||||
#
|
||||
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
|
||||
# section grades the REMAINING failure mode: git answering not at all.
|
||||
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
|
||||
tmpdirs.append(root5)
|
||||
addon5 = os.path.join(root5, "goodwe_controller")
|
||||
os.makedirs(addon5)
|
||||
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
|
||||
|
||||
check("a repository git cannot answer for reports no last-bump commit",
|
||||
last_version_change(root5) is None)
|
||||
|
||||
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
|
||||
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
|
||||
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
|
||||
"tree it could not inspect deployable",
|
||||
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
|
||||
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
|
||||
|
||||
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
|
||||
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
|
||||
# earlier draft could not tell apart. 1 and 2 mean different things to an
|
||||
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
|
||||
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
|
||||
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
|
||||
check("REFUSED is exactly 1, not merely non-zero",
|
||||
r10.returncode == 1 and "REFUSED" in r10.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")
|
||||
@@ -0,0 +1,306 @@
|
||||
"""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 SIX merges behind
|
||||
an unbumped version - its own commit message says so, and this line said
|
||||
"four" through two review rounds; 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",
|
||||
)
|
||||
|
||||
CONFIG_PATH = "goodwe_controller/config.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):
|
||||
"""git, run so it answers regardless of who owns the checkout.
|
||||
|
||||
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
|
||||
invocation this file's own docstring documents runs the container as uid 0
|
||||
against a checkout owned by the operator, and git refuses that:
|
||||
|
||||
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
|
||||
|
||||
`_git` then returned non-zero, `last_version_change()` returned None, and
|
||||
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
|
||||
reason this file exists, had never once run in its documented form.**
|
||||
|
||||
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
|
||||
check that cannot fire is not a check, and the two documented environments
|
||||
were mutually exclusive - in the container the validator ran and git
|
||||
skipped; on a bare host git ran and the validator skipped on aiohttp.
|
||||
Neither documented command ran both halves.
|
||||
|
||||
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
|
||||
the git result only when `shipped == deployed`; every real run so far
|
||||
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
|
||||
|
||||
`test_version_gate.py` could not see it either: it invokes the gate with
|
||||
`sys.executable` against fixtures it creates as its own uid, so no fixture
|
||||
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
|
||||
inside the file written to prevent it.
|
||||
|
||||
`safe.directory` is the right fix rather than `--user`: it makes the tool
|
||||
correct however it is invoked, instead of making every future caller
|
||||
remember a flag. The read is read-only, on a repository the operator is
|
||||
about to deploy from.
|
||||
"""
|
||||
where = repo or os.path.dirname(ADDON)
|
||||
return subprocess.run(
|
||||
["git", "-c", f"safe.directory={where}", "-C", where, *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def _version_at(sha, repo=None, path=CONFIG_PATH):
|
||||
"""`version:` as of one commit, or None if the file or the key is absent."""
|
||||
r = _git("show", f"{sha}:{path}", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
m = _VER.search(r.stdout)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def last_version_change(repo=None):
|
||||
"""The newest commit whose `version:` VALUE differs from its parent's.
|
||||
|
||||
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
|
||||
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
|
||||
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
|
||||
`version: "0.8.0"` - so the substring still occurs exactly once before and
|
||||
after, and `-S` never sees it. It found only the commit that first
|
||||
INTRODUCED the line.
|
||||
|
||||
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
|
||||
original packaging commit) as "the last bump", so the script reported the
|
||||
correctly-bumped tree as 87 commits behind an unbumped version - listing
|
||||
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
|
||||
entire purpose is to be trusted before a deploy cried wolf on every run.
|
||||
|
||||
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
|
||||
matches a commit whose diff touches a line with that shape - which includes
|
||||
a reformat, a quote change, or a comment moved onto the line, none of which
|
||||
are bumps. So `-G` narrows the candidates and then the VALUE is compared
|
||||
against the parent's. The question is "did the version change", and that is
|
||||
the only thing that answers it.
|
||||
|
||||
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
|
||||
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
|
||||
edit. Both were constructed in review rather than found:
|
||||
|
||||
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
|
||||
does not diff merge commits at all without `-m`, so a version line whose
|
||||
final value is decided by a conflict resolution is never a candidate.
|
||||
The lookup then names the last ordinary bump, and the merge itself counts
|
||||
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
|
||||
correctly bumped.
|
||||
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
|
||||
`app/`, then revert only `config.yaml`: the version is back where it
|
||||
started, the runtime change is still in the tree, and this reports
|
||||
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
|
||||
direction.
|
||||
|
||||
Card PKG-11 carries both. They are recorded here rather than fixed because
|
||||
a lookup that handles them costs more than the failure they represent, and
|
||||
a caveat a reader can check beats a guarantee that is not true.
|
||||
"""
|
||||
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
|
||||
repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
shas = r.stdout.split()
|
||||
for sha in shas: # newest first
|
||||
here = _version_at(sha, repo)
|
||||
there = _version_at(sha + "^", repo)
|
||||
if here is not None and here != there:
|
||||
return sha
|
||||
return shas[-1] if shas else None
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
last = last_version_change(repo)
|
||||
if not last:
|
||||
return [], None
|
||||
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:
|
||||
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
|
||||
# TRACEBACK. A missing or malformed export is the likeliest operator
|
||||
# error here, and it is a REFUSAL, not a skip - the config was not
|
||||
# checked and the script must not be re-run past it.
|
||||
opts = None
|
||||
try:
|
||||
with open(a.options, encoding="utf-8") as fh:
|
||||
opts = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
problems.append(f"--options file not found: {a.options}")
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"--options file could not be read as JSON "
|
||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||
if a.options and opts is not None:
|
||||
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 ==")
|
||||
@@ -315,6 +315,114 @@ print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
||||
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
||||
"SCHEMA BLOCK'S and not the options block's)")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
|
||||
#
|
||||
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
|
||||
# HA's "show unused optional configuration options" toggle. The line above has
|
||||
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
|
||||
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
|
||||
# displaying the number that contradicted the criterion it was evidence for.
|
||||
# That is the defect, not the count.
|
||||
#
|
||||
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
|
||||
# this section and replayed rather than paraphrased:
|
||||
#
|
||||
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
|
||||
# || e.required)
|
||||
#
|
||||
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
|
||||
# stored blob after `options:` has been deep-merged into it - so a key is
|
||||
# hideable only when it is BOTH absent from `options:` AND not required.
|
||||
#
|
||||
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
|
||||
#
|
||||
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
|
||||
# `required` on every non-`?` key, and the filter above shows a required
|
||||
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
|
||||
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
|
||||
# is the required count below. It is nowhere near 15. No arrangement of
|
||||
# `options:` can reach the target; the criterion was never satisfiable.
|
||||
#
|
||||
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
|
||||
# absent from `options:` has nothing for the Supervisor to merge into an
|
||||
# existing box's stored blob. That is exactly the `delivery_hold_s`
|
||||
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
|
||||
# cannot contain resolves to its `options:` default instead of failing
|
||||
# validation, and moving it out of `options:` is precisely how you get the
|
||||
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
|
||||
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
|
||||
# ledgers into the LOAD-BEARING source of those defaults on a live install
|
||||
# - which is the CFG-03 defect the same card's AC 4 exists to police.
|
||||
#
|
||||
# So: four fewer clicks, bought with the upgrade path and with the defaults
|
||||
# pin. The decision is written up in
|
||||
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
|
||||
# and the three assertions below are what keeps it a decision rather than an
|
||||
# omission - if anyone empties `options:` to shorten the form, this goes red.
|
||||
_hideable = sorted(set(schema) - set(defaults))
|
||||
_required = [n["name"] for n in ui if n.get("required")]
|
||||
|
||||
|
||||
def _filtered(opts):
|
||||
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
|
||||
return [n for n in ui if n["name"] in opts or n.get("required")]
|
||||
|
||||
|
||||
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
|
||||
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
|
||||
f"is not partially met, it is unstarted. Hideable: {_hideable}",
|
||||
not _hideable and len(schema) == len(defaults))
|
||||
show(f"...so replaying the frontend's own _filteredSchema on the served options "
|
||||
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
|
||||
len(_filtered(defaults)) == len(ui))
|
||||
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
|
||||
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
|
||||
# the required count to 20 would have passed while the sentence beside it stayed
|
||||
# wrong. That is the same shape as the count this repo already fixed in
|
||||
# `test_translations.py`: a number displayed but not graded.
|
||||
#
|
||||
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
|
||||
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
|
||||
# `UiOptions` marks optional because it is a list - and the assertion is an
|
||||
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
|
||||
_OPTIONAL = {k for k, v in schema.items()
|
||||
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
|
||||
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
|
||||
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
|
||||
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
|
||||
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
|
||||
_EXPECT_REQ == len(_required))
|
||||
|
||||
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
|
||||
# deliberately NOT enough on its own: both sides read the same `?` convention,
|
||||
# so marking a key optional moves BOTH and it stays green. That is correct - it
|
||||
# makes the printed number impossible to leave stale - but it grades Supervisor
|
||||
# agreeing with us, not the thing AC 3 was withdrawn for.
|
||||
#
|
||||
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
|
||||
# enough keys ever became optional that the floor approached 15, the withdrawal
|
||||
# stops being true and this file should say so rather than keep printing a
|
||||
# sentence that has quietly become wrong.
|
||||
_AC3_ASKED = 15
|
||||
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
|
||||
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
|
||||
f"goes red the withdrawal needs re-arguing, not re-asserting",
|
||||
_EXPECT_REQ > 2 * _AC3_ASKED)
|
||||
|
||||
|
||||
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
|
||||
# flag rather than a count typed here. `options:` is emptied and NOTHING else
|
||||
# varies, so the answer cannot be about anything but the merge.
|
||||
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
|
||||
f"expressible - the tab still renders {len(_filtered({}))} required "
|
||||
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
|
||||
f"withdrawn as unsatisfiable, not deferred.",
|
||||
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _entry(lang, name, path=()):
|
||||
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
@@ -239,7 +239,7 @@ configuration:
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
delivery_hold_s:
|
||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
|
||||
@@ -219,7 +219,7 @@ configuration:
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
delivery_hold_s:
|
||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
|
||||
Reference in New Issue
Block a user