Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c348974a3 | ||
|
|
3d2999337d | ||
|
|
bcee2356e0 | ||
|
|
2bb07ccdde | ||
|
|
fdaf3fb54f |
@@ -14,13 +14,20 @@ goodwe_controller/ the add-on
|
||||
hass.py Supervisor/Core API access
|
||||
store.py persistent state in /data
|
||||
mqtt.py optional status entities
|
||||
web.py ingress UI
|
||||
web.py ingress UI — routes only, status and configuration
|
||||
config_page.py the configuration page's markup, style and script
|
||||
configui.py its field model, built from the Supervisor's own
|
||||
schema + translations — owns no key, label or rule
|
||||
selfconfig.py reading and writing this add-on's own options,
|
||||
with the validate-first / compare-and-swap gate
|
||||
main.py orchestration, heartbeat, failsafe behaviour
|
||||
test_control.py runnable checks — no framework needed
|
||||
test_maintenance.py runnable checks — walks a full cycle in fake time
|
||||
DOCS.md the add-on's documentation tab
|
||||
MUTATION.md how to run a mutation so the result means something
|
||||
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
|
||||
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.8.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
|
||||
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
|
||||
what happens when nobody does.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
|
||||
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
|
||||
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
|
||||
Home Assistant's Configuration tab. Both edit the same options blob; neither is
|
||||
authoritative, the stored blob is. What the new one can do that the old one
|
||||
structurally cannot is show `validate.py`'s CROSS-FIELD refusals - two inverters
|
||||
naming one `setpoint_entity`, a `maintenance_charge_w` above `max_w`, a flat
|
||||
`setpoint_entity` left set beside a roster - **next to the field, before saving**,
|
||||
instead of as exit 78 in a log afterwards. The Supervisor's tab knows only types
|
||||
and ranges and has no way to express a rule spanning two keys.
|
||||
|
||||
Four properties are the ones that could hurt somebody, and each is checked
|
||||
rather than asserted (`test_selfconfig.py`):
|
||||
|
||||
* a blob the shipped validator refuses is **never written** - because an invalid
|
||||
blob plus a restart is exit 78, and the panel goes down with the add-on;
|
||||
* a blob that **changed underneath** the page is refused, not clobbered, and the
|
||||
keys that moved are named. Two surfaces, one blob;
|
||||
* **every message comes from the shipped validator.** Proven by inserting one
|
||||
new rule into a COPY of `validate.py` and watching it arrive on the page with
|
||||
every UI file byte-identical;
|
||||
* **nothing on the surface commands an inverter or clears a latch.** Saving does
|
||||
not even restart: applying options costs a control interruption - the
|
||||
wind-down commands 0 W on every inverter - so that is a separate button that
|
||||
says so first.
|
||||
|
||||
⚠️ **NO SCHEMA CHANGE AND NO CONTROL CHANGE.** `config.yaml` is not modified at
|
||||
all by this card: the permissions the panel needs - `ingress: true`,
|
||||
`ingress_port: 8099`, `hassio_api: true`, `homeassistant_api: true` - were
|
||||
already declared, and the panel was already live.
|
||||
`tools/supervisor_selfapi_probe.py` re-measures them against Supervisor
|
||||
2026.08.0's own code, and found things the card had wrong: Supervisor's own
|
||||
`/addons/self/options/validate` is **not** reachable from inside an add-on, and
|
||||
the Configuration tab **can** be removed - at the cost of all 71 options, which
|
||||
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
|
||||
@@ -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.
|
||||
@@ -40,6 +40,69 @@ hardware holds it there.
|
||||
|
||||
## Configuration
|
||||
|
||||
### ⚠️ There are TWO places to configure this add-on (UX-02)
|
||||
|
||||
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
||||
there is one options blob, and neither surface is a copy of it.
|
||||
|
||||
| | Ingress panel → **Configuration** | Supervisor → **Configuration** tab |
|
||||
|---|---|---|
|
||||
| **Use it for** | everyday editing | recovery, and when the add-on is down |
|
||||
| **Sections, units, help** | real headings, units beside the boxes, the description under each field | one flat list of 71 fields, no headings, no units |
|
||||
| **Tells you a setting is wrong** | **yes — before you save**, next to the field | no. It knows types and ranges only |
|
||||
| **Can express a rule spanning two keys** | yes, because it runs the add-on's own validator | **no.** The schema grammar cannot say "must be at or below `max_w`" |
|
||||
| **Available when the add-on is not running** | **no** — it is served *by* the add-on | **yes** |
|
||||
| **Editing the inverter list** | cards you add and remove, with the per-entry labels rendered | a repeatable section whose per-entry keys render **raw** on frontend 20250925.1 |
|
||||
|
||||
**Which one is authoritative: neither.** The stored options blob is, and both
|
||||
are editors of it. `DECISIONS/UX-02-two-config-surfaces.md` is the record, with
|
||||
the measurements behind it.
|
||||
|
||||
The one thing you need to know in practice: **the panel refuses to save a
|
||||
configuration the add-on would not start on, and the Configuration tab does
|
||||
not.** The tab will happily store `maintenance_charge_w: 2500` beside
|
||||
`max_w: 2000`; the add-on then exits 78 on its next start, the panel goes down
|
||||
with it, and the tab is where you fix it. That asymmetry is the reason both
|
||||
exist.
|
||||
|
||||
#### What the panel does when you press Save
|
||||
|
||||
1. It runs the **shipped** validator — the same `validate_startup_config()` and
|
||||
`validate_unit_ranges()` the add-on runs at startup, not a copy of them — and
|
||||
if anything is refused, **nothing is written** and your edits stay on screen.
|
||||
2. It re-reads the stored blob and refuses if it changed while you were editing
|
||||
— someone in the Configuration tab, or you on another device. It names the
|
||||
keys that moved and shows both values; pressing Save again then writes yours.
|
||||
3. It writes the whole blob. It does **not** restart: `POST …/options` replaces
|
||||
the stored options, and a key left out would revert to the shipped default,
|
||||
so the panel always sends everything it read.
|
||||
|
||||
#### ⚠️ Saving costs nothing. Applying costs a control interruption
|
||||
|
||||
Stored settings take effect when the add-on next starts. The panel shows which
|
||||
stored settings are not what the add-on is currently running, and **Restart to
|
||||
apply** is a separate button with its own confirmation — because the wind-down
|
||||
commands **0 W on every inverter** before the process exits, and the battery
|
||||
does nothing until it is back. Nothing else is changed by that restart: a
|
||||
latched sign-disagreement failsafe is restored from the Store on the way back
|
||||
up, not cleared.
|
||||
|
||||
#### Two range checks can be skipped, and the page says so
|
||||
|
||||
Two of the refusals compare a configured ceiling against the setpoint device's
|
||||
**own** reported range, which means asking Home Assistant. If ESPHome has not
|
||||
come up, that range is unknown and those two checks are skipped — on the page
|
||||
and at startup, identically. The page prints a note saying so rather than
|
||||
looking green for a reason you cannot see. If the device answers later and
|
||||
disagrees, the add-on refuses to start then, with the same sentence.
|
||||
|
||||
#### On a phone
|
||||
|
||||
One column, big targets, sections collapsed until you open them, and the
|
||||
refusals pinned where you cannot scroll past them. It is the screen for
|
||||
standing in front of the inverter.
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
@@ -52,7 +115,7 @@ whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **All 70 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||
@@ -1446,6 +1509,12 @@ 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,
|
||||
@@ -1460,8 +1529,8 @@ reports as success:
|
||||
|
||||
**The silent no-op.** The Supervisor keys the built image by `version:` in
|
||||
`config.yaml`. `ha apps update` against an unchanged version installs nothing
|
||||
and says it worked. This happened twice: PKG-06 (four merges behind an unbumped
|
||||
version) and PKG-07 (six). Both times a human noticed. Nothing else would have.
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
@@ -512,7 +512,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
|
||||
@@ -588,6 +588,36 @@ 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_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,7 +248,7 @@ for what, text in BAD:
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"70 options and 7 per-entry keys came back out of each, which is what "
|
||||
"71 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
@@ -355,7 +355,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 "
|
||||
|
||||
@@ -140,6 +140,66 @@ 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)
|
||||
|
||||
|
||||
print("\nthe shipped tree's own version is readable")
|
||||
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ RUNTIME = (
|
||||
"goodwe_controller/build.yaml",
|
||||
)
|
||||
|
||||
CONFIG_PATH = "goodwe_controller/config.yaml"
|
||||
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
|
||||
|
||||
|
||||
@@ -86,6 +87,51 @@ def _git(*args, repo=None):
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -93,11 +139,9 @@ def runtime_commits_since_version_change(repo=None):
|
||||
clean; an empty list with None means git could not answer and the caller
|
||||
must NOT read that as a pass.
|
||||
"""
|
||||
r = _git("log", "-1", "--format=%H", "-S", 'version: "',
|
||||
"--", "goodwe_controller/config.yaml", repo=repo)
|
||||
if r.returncode != 0 or not r.stdout.strip():
|
||||
last = last_version_change(repo)
|
||||
if not last:
|
||||
return [], None
|
||||
last = r.stdout.strip()
|
||||
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return [], None
|
||||
@@ -144,8 +188,20 @@ def main():
|
||||
print(" no runtime change since the last bump - nothing to deploy")
|
||||
|
||||
if a.options:
|
||||
with open(a.options, encoding="utf-8") as fh:
|
||||
opts = json.load(fh)
|
||||
# ⚠️ 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
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""UX-02: can this add-on read and write its OWN options, and what comes back?
|
||||
|
||||
⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`,
|
||||
`POST /addons/self/options` and `POST /addons/self/restart` are permitted to an
|
||||
add-on with `hassio_api: true` and no special `hassio_role`, and it says - in
|
||||
its own words - "confirm this again in the PR by calling it, rather than citing
|
||||
this card". A permission read off a regex in somebody else's repository is a
|
||||
hypothesis; the whole configuration surface rests on it, so it is RUN here,
|
||||
against the compiled patterns out of the release image this site runs.
|
||||
|
||||
It also answers the question the panel's design depends on next: WHAT the info
|
||||
response carries. app/configui.py builds the entire form from `schema` and
|
||||
`translations` and owns no copy of either, which is only sound if those two
|
||||
really are in the response and really are the loader's own output. Both are
|
||||
constructed here, out of the image, and this repository's own model is built
|
||||
from them.
|
||||
|
||||
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
||||
2026.08.0 is what 192.168.2.158 ran when UX-02 landed, and it is the same image
|
||||
tools/supervisor_shape_probe.py pins):
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_selfapi_probe.py
|
||||
|
||||
⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads
|
||||
config.yaml and translations/, imports Supervisor's own modules and runs them in
|
||||
a throwaway container. It opens no socket, contacts no Supervisor, POSTs no
|
||||
options and restarts nothing. The add-on is live in a real house; the only
|
||||
honest way to measure this is against a copy of the software, not against it.
|
||||
|
||||
⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request
|
||||
against a running Supervisor, because doing that would mean pointing it at the
|
||||
live one. So §A grades the ACCESS DECISION (the compiled patterns, run against
|
||||
the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data`
|
||||
builds its dict from), rather than a captured response body. What is NOT
|
||||
verified here is the wiring between the two - that the handler which builds that
|
||||
dict is the one mounted at that path. That is read, not run, and this is the
|
||||
sentence saying so.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
sys.path.insert(0, "/") # app/ is mounted at /app
|
||||
|
||||
import voluptuous as vol # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
from supervisor.utils.yaml import read_yaml_file # noqa: E402
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402
|
||||
from supervisor.apps.options import AppOptions, UiOptions # noqa: E402
|
||||
from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402
|
||||
from supervisor.store.data import _read_app_translations # noqa: E402
|
||||
from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402
|
||||
ATTR_TRANSLATIONS)
|
||||
from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402
|
||||
|
||||
from app import configui # noqa: E402
|
||||
|
||||
FAILED = []
|
||||
|
||||
|
||||
def show(title, ok, detail=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {title}")
|
||||
if detail:
|
||||
print(" " + str(detail)[:400])
|
||||
if not ok:
|
||||
FAILED.append(title)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("== A. THE PERMISSION, RUN RATHER THAN READ ==")
|
||||
# ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped.
|
||||
# `token_validation()` tests `api_bypass` against `request.path` for an
|
||||
# installed app BEFORE it consults `access_hassio_api` or any role, so a match
|
||||
# here is the whole permission question for these paths.
|
||||
for path, want in (("/addons/self/info", True),
|
||||
("/addons/self/options", True),
|
||||
("/addons/self/restart", True),
|
||||
("/addons/self/options/config", True),
|
||||
# The two the pattern explicitly excludes, asserted so that
|
||||
# a future widening of it does not go unnoticed here.
|
||||
("/addons/self/security", False),
|
||||
("/addons/self/update", False)):
|
||||
got = bool(_V1_PATTERNS.api_bypass.match(path))
|
||||
show(f"{path:32s} bypass={got}", got == want)
|
||||
|
||||
# ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING.
|
||||
# Supervisor has its OWN options validator at `/addons/{app}/options/validate`.
|
||||
# It is two path segments after `self`, so `[^/]+` cannot match it, and it is
|
||||
# not one of the listed literals either. An add-on cannot call it. That does
|
||||
# not hurt this card - the Supervisor validator knows only types and ranges, and
|
||||
# the whole reason this panel exists is the cross-field rules it structurally
|
||||
# cannot express - but "there is exactly one validator reachable from inside the
|
||||
# add-on, and it is ours" is a fact the design should rest on knowingly.
|
||||
_val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate"))
|
||||
show("/addons/self/options/validate is NOT reachable from an add-on - so the "
|
||||
"ONLY validator this panel can call is app/validate.py, which is the one "
|
||||
"with the cross-field rules", not _val, f"bypass={_val}")
|
||||
|
||||
# ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE
|
||||
# THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default`
|
||||
# role admits none of the three, and that FAILED: the default role's pattern is
|
||||
# `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the
|
||||
# split is:
|
||||
#
|
||||
# /addons/self/info - admitted by BOTH the bypass and the default role
|
||||
# /addons/self/options - the bypass ONLY
|
||||
# /addons/self/restart - the bypass ONLY
|
||||
#
|
||||
# i.e. the two WRITING paths rest entirely on the bypass list, and if it were
|
||||
# narrowed this panel could still read its own options and could no longer save
|
||||
# them. That is a more useful thing to know than the tidier claim that was
|
||||
# asserted first, and it is only known because the claim was run.
|
||||
_default = _V1_PATTERNS.role_access["default"]
|
||||
show("`/addons/self/info` is admitted by the DEFAULT role as well as by the "
|
||||
"bypass (`/.+/info`) - so reading has two independent licences",
|
||||
bool(_default.match("/addons/self/info")))
|
||||
show("...while `/addons/self/options` and `/addons/self/restart` are admitted "
|
||||
"by the BYPASS ONLY. The two writing paths rest on that list alone",
|
||||
not _default.match("/addons/self/options")
|
||||
and not _default.match("/addons/self/restart"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==")
|
||||
# `AppsAPI.info_data()` builds its dict with these constants as keys. Their
|
||||
# VALUES are what the panel reads off the JSON, so they are asserted here
|
||||
# rather than typed into app/selfconfig.py as a guess.
|
||||
show("the response keys the panel reads are named `options`, `schema` and "
|
||||
"`translations`",
|
||||
(ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS)
|
||||
== ("options", "schema", "translations"),
|
||||
f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==")
|
||||
raw = read_yaml_file(Path("/w/config.yaml"))
|
||||
cfg = SCHEMA_APP_CONFIG(raw)
|
||||
schema, defaults = cfg["schema"], cfg["options"]
|
||||
ui = UiOptions(None)(schema)
|
||||
show(f"UiOptions renders every key config.yaml declares ({len(ui)})",
|
||||
len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}")
|
||||
|
||||
# ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` /
|
||||
# `lengthMax` - which reads like a string length and is not - and app/configui.py
|
||||
# copies those two names straight through into the form's `min`/`max`. Guessing
|
||||
# `valueMin` would have produced a form with no bounds at all and no error
|
||||
# anywhere.
|
||||
_maxw = [n for n in ui if n["name"] == "max_w"][0]
|
||||
show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), "
|
||||
"which is why app/configui.py reads those two names",
|
||||
_maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000,
|
||||
json.dumps(_maxw))
|
||||
_sel = [n for n in ui if n["name"] == "meter_source"][0]
|
||||
show("an enum arrives as type `select` with its `options` listed",
|
||||
_sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []),
|
||||
json.dumps(_sel))
|
||||
_inv = [n for n in ui if n["name"] == "inverters"][0]
|
||||
show("the roster arrives as type `schema` + `multiple`, carrying its per-entry "
|
||||
"fields - which is what the panel's roster editor draws cards from",
|
||||
_inv.get("type") == "schema" and _inv.get("multiple") is True
|
||||
and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity",
|
||||
"max_w"],
|
||||
json.dumps([s["name"] for s in _inv["schema"]]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==")
|
||||
# ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is
|
||||
# `deepmerge(config.yaml defaults, the STORED user options)` and the setter is
|
||||
# `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a
|
||||
# POST does not keep its previous value; it falls back to the config.yaml
|
||||
# DEFAULT. Both halves are run below on real objects.
|
||||
_persist_before = {"max_w": 4200, "gain": 0.42}
|
||||
_effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_before))
|
||||
show("stored user options merge OVER the config.yaml defaults, so every key is "
|
||||
"present in what an add-on reads back",
|
||||
_effective_before["max_w"] == 4200
|
||||
and set(_effective_before) == set(defaults),
|
||||
f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}")
|
||||
|
||||
_persist_after = {"gain": 0.42} # a POST that simply omits max_w
|
||||
_effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
||||
copy.deepcopy(_persist_after))
|
||||
show("...and a POST that OMITS a key does not preserve it - the key reverts to "
|
||||
"config.yaml's default. That is why this panel posts the WHOLE blob it "
|
||||
"read, and why it refuses to post one built on a stale read",
|
||||
_effective_after["max_w"] == defaults["max_w"] != 4200,
|
||||
f"omitted max_w -> {_effective_after['max_w']} "
|
||||
f"(default {defaults['max_w']}, was 4200)")
|
||||
|
||||
# The blob the panel would actually POST: the full merged options, unchanged.
|
||||
# If Supervisor refused that shape the read-modify-write would be unusable.
|
||||
_v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
_validated = _v(copy.deepcopy(_effective_before))
|
||||
show("the WHOLE merged blob - what this panel sends back - is accepted by "
|
||||
"Supervisor's own options validator", True)
|
||||
except vol.Invalid as err:
|
||||
_validated = None
|
||||
show("the WHOLE merged blob is accepted", False, str(err))
|
||||
|
||||
# ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the
|
||||
# operator which settings are "stored but not running" by comparing the stored
|
||||
# blob with `/data/options.json`. Supervisor writes that file through
|
||||
# `schema.validate(self.options)`, so any COERCION in that step would show up
|
||||
# as a spurious difference on every page load.
|
||||
if _validated is not None:
|
||||
_drift = sorted(k for k in set(_validated) | set(_effective_before)
|
||||
if _validated.get(k) != _effective_before.get(k))
|
||||
show("...and validation does not COERCE it, so `stored vs running` compares "
|
||||
"like with like and shows a difference only when there is one. "
|
||||
f"Keys changed by validation: {_drift}", not _drift)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==")
|
||||
translations = _read_app_translations(Path("/w"))
|
||||
show("Supervisor's own loader accepts both files, so `info['translations']` "
|
||||
"carries them", set(translations) == {"en", "nl"}, sorted(translations))
|
||||
|
||||
for lang, groups_expected in (("en", 7), ("nl", 7)):
|
||||
model = configui.field_model(ui, translations, lang)
|
||||
names = [g["name"] for g in model["groups"]]
|
||||
show(f"[{lang}] this repository's own field_model() turns those into "
|
||||
f"{len(names)} real section headings out of UX-01's `Group · ` prefix "
|
||||
f"- which the Supervisor's flat list cannot render at all",
|
||||
len(names) == groups_expected and not model["ungrouped"],
|
||||
f"{names} ungrouped={model['ungrouped']}")
|
||||
_raw = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if f["label_lang"] == "raw"]
|
||||
show(f"[{lang}] ...and not one field falls back to its raw key name. "
|
||||
f"Raw: {_raw}", not _raw)
|
||||
_nohelp = [f["key"] for g in model["groups"] for f in g["fields"]
|
||||
if not f["help"]]
|
||||
show(f"[{lang}] ...and not one is left without the sentence that says "
|
||||
f"whether to touch it. Missing: {_nohelp}", not _nohelp)
|
||||
|
||||
# ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON
|
||||
# THE SAME DATA, and shape_probe §F is the measurement that makes it a claim
|
||||
# rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to
|
||||
# `ha-selector-object`, which labels them `fields[name].label || name`, and the
|
||||
# converter sets no label - so they render RAW there, however well UX-01
|
||||
# translated them. Here they are read out of the same `fields:` block and shown.
|
||||
_model_en = configui.field_model(ui, translations, "en")
|
||||
_roster = [f for g in _model_en["groups"] for f in g["fields"]
|
||||
if f["key"] == "inverters"][0]
|
||||
_sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]]
|
||||
show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is "
|
||||
"exactly what the Supervisor's Configuration tab renders raw on frontend "
|
||||
"20250925.1 (shape_probe §F)",
|
||||
all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7,
|
||||
json.dumps([(k, l) for k, l, _ in _sub]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==")
|
||||
# app/configui.py files each validator sentence under every option key it
|
||||
# NAMES, with word boundaries. Three of this schema's keys are suffixes of one
|
||||
# another, so a naive `in` test would file every per-unit refusal under the site
|
||||
# key as well. Run against the real key list rather than a hand-made one.
|
||||
_keys = sorted(defaults)
|
||||
_collide = [k for k in _keys if any(o != k and o.endswith("_" + k)
|
||||
for o in _keys)]
|
||||
show(f"config.yaml really does contain keys that are suffixes of other keys, "
|
||||
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
||||
_by, _un = configui.problems_by_field(
|
||||
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys)
|
||||
show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
||||
"`sign_disagree_w`",
|
||||
sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un,
|
||||
json.dumps(sorted(_by)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==")
|
||||
# ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR
|
||||
# THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is
|
||||
# imposed is one nobody re-examines, while a choice is something a decision
|
||||
# record has to justify. So it was RUN.
|
||||
#
|
||||
# The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`.
|
||||
# Delete `schema:` from config.yaml and there is nothing to render. Supervisor
|
||||
# ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab
|
||||
# really would go away.
|
||||
#
|
||||
# What goes away with it is the add-on. `AppOptions` DROPS every option that
|
||||
# the schema does not declare (one "Option 'x' does not exist in the schema"
|
||||
# warning per key, and nothing else), so `App.options` validates down to the
|
||||
# EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and
|
||||
# `load_options()` hands the controller nothing. The startup gate then refuses
|
||||
# it for having no actuator - exit 78 - and the ingress panel this card builds
|
||||
# is not running either, because the add-on serving it is not.
|
||||
#
|
||||
# So: removable, at the cost of every setting on the site and the add-on with
|
||||
# them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md
|
||||
# keeps the tab, and it is a much better reason than "the platform will not
|
||||
# let us".
|
||||
_noschema = {k: v for k, v in raw.items() if k != "schema"}
|
||||
try:
|
||||
_cfg2 = SCHEMA_APP_CONFIG(_noschema)
|
||||
_accepted, _detail = True, repr(_cfg2.get("schema"))
|
||||
except vol.Invalid as err:
|
||||
_cfg2, _accepted, _detail = None, False, str(err)
|
||||
show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its "
|
||||
"`schema_ui` is empty - so the Configuration tab is removable after all",
|
||||
_accepted and _cfg2.get("schema") == {}
|
||||
and UiOptions(None)(_cfg2["schema"]) == [], _detail)
|
||||
if _accepted:
|
||||
_v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"],
|
||||
_cfg2["name"], "local_x")))
|
||||
try:
|
||||
_out2 = _v2(copy.deepcopy(_cfg2["options"]))
|
||||
_kept = len(_out2)
|
||||
except vol.Invalid as err:
|
||||
_kept = f"refused: {err}"
|
||||
show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in "
|
||||
"the schema are discarded with one log warning each, so the add-on "
|
||||
"would start on an empty configuration, be refused by its own startup "
|
||||
"gate for having no actuator, and take the ingress panel down with "
|
||||
"it. That is why the tab stays",
|
||||
_kept == 0, f"options surviving an empty schema: {_kept} "
|
||||
f"of {len(defaults)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
||||
for title in FAILED:
|
||||
print(" - " + title)
|
||||
sys.exit(1)
|
||||
print("== all cases passed ==")
|
||||
@@ -3,8 +3,8 @@
|
||||
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 70
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY are
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 71
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user