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>
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 afterself, 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 isapp/validate.py. Which is the one we want: Supervisor's knows only types and ranges./addons/self/infois admitted by thedefaultrole as well as by the bypass (/.+/info), while/addons/self/optionsand/addons/self/restartare 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:
- 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 byapp/configui.py, which contains no option key, no label and no rule. - 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. - Validation happens before the compare, and a refused blob is never written. Not warned about — not written.
- 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.