From fdaf3fb54f3308ca71ed418f77d2d49a70971b62 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 6 Sep 2026 21:38:31 +0200 Subject: [PATCH] UX-02: the validator's refusals, next to the field, before the save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- README.md | 9 +- goodwe_controller/CHANGELOG.md | 50 ++ .../DECISIONS/UX-02-two-config-surfaces.md | 207 +++++ goodwe_controller/DOCS.md | 71 +- goodwe_controller/app/config_page.py | 562 +++++++++++++ goodwe_controller/app/configui.py | 315 +++++++ goodwe_controller/app/selfconfig.py | 372 ++++++++ goodwe_controller/app/web.py | 189 ++++- goodwe_controller/test_selfconfig.py | 796 ++++++++++++++++++ goodwe_controller/test_translations.py | 4 +- .../tools/supervisor_selfapi_probe.py | 334 ++++++++ goodwe_controller/translations/en.yaml | 2 +- 12 files changed, 2903 insertions(+), 8 deletions(-) create mode 100644 goodwe_controller/DECISIONS/UX-02-two-config-surfaces.md create mode 100644 goodwe_controller/app/config_page.py create mode 100644 goodwe_controller/app/configui.py create mode 100644 goodwe_controller/app/selfconfig.py create mode 100644 goodwe_controller/test_selfconfig.py create mode 100644 goodwe_controller/tools/supervisor_selfapi_probe.py diff --git a/README.md b/README.md index e40327d..fdd9eb5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 4cebec5..7aa05a9 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DECISIONS/UX-02-two-config-surfaces.md b/goodwe_controller/DECISIONS/UX-02-two-config-surfaces.md new file mode 100644 index 0000000..76a57dc --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-02-two-config-surfaces.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index c1e624a..3fc3191 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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, diff --git a/goodwe_controller/app/config_page.py b/goodwe_controller/app/config_page.py new file mode 100644 index 0000000..11e2f33 --- /dev/null +++ b/goodwe_controller/app/config_page.py @@ -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//`), 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 = """ + +GoodWe - Configuration + +
+

GoodWe - Configuration

+

← Status and controls

+ +
+ This page and the Supervisor's Configuration tab edit the same settings. + Both write one options blob. This page is the one that can tell you a + setting is wrong before 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. +
+ +
+ Saving does not restart anything, and does not take effect on its own. + Stored settings are applied when the add-on next starts. Restarting to + apply them costs a short loss of control: the add-on commands + 0 W on every inverter as it shuts down, and the battery does nothing + until it is back up. That is a separate button, below, with its own + confirmation. +
+ + +
+
+
+ +
+ + + + loading… +
+ + +

Restart the add-on?

+

The add-on will command 0 W on every inverter, 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.

+
+ + +
+
+ + +""" diff --git a/goodwe_controller/app/configui.py b/goodwe_controller/app/configui.py new file mode 100644 index 0000000..df6fa1d --- /dev/null +++ b/goodwe_controller/app/configui.py @@ -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"(? 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": }` 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)} diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index f2acb85..a21b736 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -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 = """ @@ -49,6 +70,7 @@ PAGE = """ .muted{color:var(--muted);font-size:13px}

GoodWe RS485 Controller

+

Configuration →

@@ -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() diff --git a/goodwe_controller/test_selfconfig.py b/goodwe_controller/test_selfconfig.py new file mode 100644 index 0000000..74f55f1 --- /dev/null +++ b/goodwe_controller/test_selfconfig.py @@ -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("