Files
goodwe-addon/goodwe_controller/DECISIONS/UX-02-two-config-surfaces.md
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00

11 KiB

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.

Amended by UX-15-setup-only-mode (2026-09-28): one refused configuration keeps the panel: options that name no inverter at all and have no other refusal, which is what a new install ships. The add-on then serves the page in setup-only mode and commands nothing. Every other refusal still exits 78.

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.