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>
This commit is contained in:
2026-09-28 08:48:21 +02:00
co-authored by Claude Opus 5.5
parent 4de96752ff
commit 159d7461d8
20 changed files with 1075 additions and 71 deletions
+42 -11
View File
@@ -31,6 +31,8 @@ of Home Assistant's entity list (app/haentities.py) and writes nothing.
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
not options is a Store key, `setup_done`, written when the wizard's save
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
commands nothing; there `/action` answers 409.
"""
import io
@@ -136,6 +138,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
what = body.get("action")
if getattr(controller, "setup_only", False):
# UX-15: nothing to start or stop until setup is saved.
return web.json_response(
{"ok": False, "error": messages.row_setup_only()}, status=409)
result = await controller.handle_action(what)
if result is None:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
@@ -149,14 +155,23 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""(body, options, model). UX-13's settings view posts its page
`model`, which app/uimodel.py turns into the options blob here, so the
validator and the Supervisor only ever see a real blob. The classic
page posts `options`. Raises DeriveError for a model no blob can say."""
page posts `options`. Raises DeriveError for a model no blob can say.
With `stored` (a save) and the `base` model the page opened, only the
keys this page changed are written onto what is stored now, so a save
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
return body, uipage.to_options(model, stored), model
mine = uipage.to_options(model, stored)
base = body.get("base")
if stored is not None and isinstance(base, dict):
was, _errors = uipage.to_options_partial(base, stored)
mine = uipage.only_edits(mine, was, stored)
return body, mine, model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
@@ -202,6 +217,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
options, store_data(), bool(getattr(controller, "auto", False)),
fresh=getattr(getattr(controller, "store", None), "fresh",
False) is True),
# UX-15: this process serves setup only and commands nothing.
"setup_only": getattr(controller, "setup_only", False) is True,
})
return web.json_response(model)
@@ -219,11 +236,19 @@ def build_app(controller, selfcfg=None) -> web.Application:
be a second opinion about a rule it does not own.
"""
try:
_body, options, model = await _proposed(request)
except DeriveError as err:
return web.json_response({"problems": [], "by_key": {},
"unanchored": [], "notes": [],
"messages": [uipage.derive_message(err)]})
_body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = _body.get("model")
errors = []
if isinstance(model, dict):
# UX-15 F1: a part the page cannot turn into options blocks its
# own step, and everything else is still checked.
options, errors = uipage.to_options_partial(model)
else:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -237,8 +262,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = uipage.anchors(problems, notes, model,
_keys_of(options))
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options)) if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
@@ -286,10 +313,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError as err:
except DeriveError:
model = posted["model"]
_opts, errors = uipage.to_options_partial(model, stored_now)
messages_ = [uipage.derive_message(e) for e in errors]
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": [uipage.derive_message(err)]},
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
expect = str(body.get("fingerprint") or "")
try: