UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s

The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-06 21:38:31 +02:00
co-authored by Claude Opus 5
parent 3368231048
commit fdaf3fb54f
12 changed files with 2903 additions and 8 deletions
+8 -1
View File
@@ -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)
+50
View File
@@ -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.
+70 -1
View File
@@ -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,
+562
View File
@@ -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="./">&larr; 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&hellip;</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>
"""
+315
View File
@@ -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))
+372
View File
@@ -0,0 +1,372 @@
"""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()
units = build_roster(opts, None)
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)}
+186 -3
View File
@@ -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 &rarr;</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()
+796
View File
@@ -0,0 +1,796 @@
"""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("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")
+2 -2
View File
@@ -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 "
@@ -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 ==")
+1 -1
View File
@@ -3,7 +3,7 @@
# ⚠️ 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
# 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 are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.