UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s

The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
2026-09-28 05:31:18 +02:00
co-authored by Claude Opus 5.5
parent 515c11b380
commit be57b87c34
19 changed files with 1885 additions and 59 deletions
+66 -6
View File
@@ -28,19 +28,23 @@ 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. UX-13's `ha/entities` is a GET
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).
"""
import io
import json
import logging
import os
from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .uimodel import DeriveError, options_to_ui
@@ -103,6 +107,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
# save can name only the keys that moved underneath the page (finding a).
handed: dict = {}
def hand_out(options):
fp = selfconfig.fingerprint(options)
handed.pop(fp, None)
handed[fp] = options
while len(handed) > 16:
handed.pop(next(iter(handed)))
return fp
def store_data():
store = getattr(controller, "store", None)
data = getattr(store, "data", None)
return data if isinstance(data, dict) else None
async def index(_request):
return web.Response(text=index_page, content_type="text/html")
@@ -165,7 +185,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"fingerprint": hand_out(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
@@ -176,6 +196,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
options, store_data(), bool(getattr(controller, "auto", False))),
})
return web.json_response(model)
@@ -213,8 +237,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
if model is not None:
payload["messages"] = 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)]
return web.json_response(payload)
async def _setup_checks(options):
"""The wizard's live checks: a list at most a few seconds old, and
what the running add-on already knows (its meter, today's prices)."""
rows, age = await entities.get(max_age=5.0)
if age is not None and age >= 30.0:
rows = None # Home Assistant is not answering
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
meter_now_w=getattr(controller, "grid", None),
prices_known=known)
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
@@ -249,21 +292,26 @@ def build_app(controller, selfcfg=None) -> web.Application:
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
hass=controller.hass,
base=handed.get(expect))
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
# The fields to look at are the ones the OTHER surface moved,
# not this page's own edits (UX-13 P2 test, finding a).
moved = (refused.changed if refused.moved is None
else refused.moved)
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"changed_fields": (uipage.fields_of(refused.changed,
model)
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"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)},
"fingerprint": hand_out(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
@@ -272,6 +320,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
if model is not None:
payload["messages"] = uipage.anchors(refused.problems, [],
model, _keys_of(options))
payload["steps"] = uipage.step_problems(payload["messages"],
model)
return web.json_response(payload, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
@@ -279,6 +329,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
# `handed` keeps `expect`: another page may have opened the same blob.
store = getattr(controller, "store", None)
if (model is not None and body.get("setup") == "done"
and store is not None):
store.set(uipage.SETUP_DONE,
datetime.now(timezone.utc).isoformat(timespec="seconds"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -310,6 +366,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
one = str(request.query.get("entity", "") or "")[:255]
if one:
# UX-13 P4's kettle test: one entity, read now.
return web.json_response(await entities.reading(one))
return web.json_response(await entities.payload(kind))
app = web.Application()