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
+130 -9
View File
@@ -9,7 +9,8 @@ Part 6 the save through the P2 path, and every blob the wizard can produce
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
save names only what changed elsewhere (P2 test, finding a). Part 8 the
words. The headless-browser click-through is in the PR.
words. Part 9 UX-15's fixes: F1, F2 with two tabs, a missing meter entity and
step 1 after a skip. The headless-browser click-through is in the PR.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
@@ -51,6 +52,7 @@ DEFAULTS = D.defaults(D.CONFIG)
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
UI_TEXT = uipage.ui_text()
WS = UI_TEXT["settings"]["setup"]
WS_D = UI_TEXT["settings"]["derive"]
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
@@ -626,11 +628,15 @@ print(f" {len(_all)} blobs from {len(B.starts())} starts; "
check(f"every plan from a new install is accepted by the shipped validator "
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
if r])
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
check(f"every plan from a new install as shipped (no power request entity, "
f"setup-only mode) is accepted ({len(_empty)} plans)",
len(_empty) == len(_new) and not [n for n, r in _empty if r])
check("every plan from the TEST-23 sites is accepted, except where the site "
"trades on price with a plan that has no meter or no prices, which the "
"last step then lists",
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
for n, _b, r in _all) and len(_ok) >= 80)
for n, _b, r in _all) and len(_ok) >= 100)
check("every accepted blob keeps every option key (the Supervisor count, 78)",
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
@@ -645,9 +651,11 @@ async def _stale(s, root, sup):
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12 # the operator's own edit
first = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
{"model": model, "base": m["ui"],
"fingerprint": m["fingerprint"]})
again = await _post(s, root + "config/save",
{"model": model, "fingerprint": first[1]["fingerprint"]})
{"model": model, "base": m["ui"],
"fingerprint": first[1]["fingerprint"]})
return first, again
@@ -659,12 +667,13 @@ check("the banner's list is the other surface's key only, not the operator's "
"own edit", _first_r[0] == 409
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
and _first_r[1]["moved"] == ["peak_cap_w"])
check("`changed` (stored against mine, which the classic page shows as "
"theirs -> mine) is unchanged",
_first_r[1]["changed"] == ["peak_cap_w", "price_timeout_s"])
check("`changed` (stored against what this page would write) is only the "
"page's own edit, since a save writes only that (UX-15 F2)",
_first_r[1]["changed"] == ["price_timeout_s"])
check("saving again with the new token is the deliberate second press: it "
"stores the page as shown (Discard loads theirs instead)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 3500
"stores this page's own edit and keeps the other surface's change "
"(UX-15 F2)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 5000
and _sup.options["price_timeout_s"] == 12)
@@ -757,6 +766,118 @@ _leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
check(f"settings.js, wizard included, names no option key ({_leak})",
not _leak)
# --------------------------------------------------------------------------- #
print("\n9. UX-15: F1, F2, a missing meter entity, and step 1 after a skip")
# --------------------------------------------------------------------------- #
async def _f1(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
price = copy.deepcopy(m["ui"])
price.update({"ui.contract": "daynight", "ui.fallback": "daynight",
"ui.day_price": None, "ui.night_price": 0.25})
none = copy.deepcopy(m["ui"])
none["inverters"] = []
none[U.COUNT] = 0
return {"price": (await _post(s, root + "config/validate",
{"model": price, "setup": True}))[1],
"none": (await _post(s, root + "config/validate",
{"model": none}))[1],
"save": await _post(s, root + "config/save", {
"model": price, "base": m["ui"], "setup": "done",
"fingerprint": m["fingerprint"]})}
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
SITE, _f1))
print(f" empty day price: steps {_r['price']['steps']}")
check("F1: a price the page cannot turn into options blocks step 4, beside "
"its own field",
_r["price"]["steps"]["4"] >= 1
and {"field": "ui.day_price", "card": None, "tier": "bad",
"text": WS_D["no_price"]["description"]} in _r["price"]["messages"])
check("F1: ...and the last step's live checks still run on the rest",
[x["tier"] for x in _r["price"].get("setup", [])
if "number.inv1_power" in x["text"]] == ["ok"]
and any("402 W" in x["text"] for x in _r["price"]["setup"]))
check("F1: no inverter card blocks step 2, not the last step alone",
_r["none"]["steps"]["2"] >= 1)
check("F1: the save refuses it (422) and says which step to go back to",
_r["save"][0] == 422 and _r["save"][1]["steps"]["4"] >= 1
and _sup.writes == [])
async def _tabs(s, root, sup):
"""Two tabs open one blob. A saves a peak limit; B edited only the
timeout, gets the 409, and presses Save again."""
a = await _get(s, root + "config/model?lang=en")
b = await _get(s, root + "config/model?lang=en")
va = copy.deepcopy(a["ui"])
va["peak_cap_w"] = 4000
ra = await _post(s, root + "config/save", {
"model": va, "base": a["ui"], "fingerprint": a["fingerprint"]})
after_a = copy.deepcopy(sup.options)
vb = copy.deepcopy(b["ui"])
vb["price_timeout_s"] = 12
first = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": b["fingerprint"]})
again = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": first[1]["fingerprint"]})
return ra, after_a, first, again
(_ra, _after_a, _first_b, _again_b), _sup = asyncio.run(
session(Controller(SITE, STATES), dict(SITE, peak_cap_w=3600), _tabs))
_moved = sorted(k for k in _sup.options
if _sup.options[k] != _after_a.get(k))
print(f" A: {_ra[0]}, B: {_first_b[0]} then {_again_b[0]}; B's re-save "
f"moved {_moved}")
check("F2: two tabs. A saves peak 4.0; B, which edited only the timeout, "
"gets the 409 naming the peak limit",
_ra[0] == 200 and _first_b[0] == 409
and _first_b[1]["changed_fields"] == ["peak_cap_w"])
check("F2: B's second press keeps A's peak 4.0 and writes only B's own key",
_again_b[0] == 200 and _sup.options["peak_cap_w"] == 4000
and _sup.options["price_timeout_s"] == 12
and _moved == ["price_timeout_s"])
_both = uipage.only_edits({"a": 2, "b": 1, "c": 5}, {"a": 1, "b": 1, "c": 3},
{"a": 1, "b": 9, "c": 4, "d": 0})
check("F2: only_edits() keeps every stored key the page did not edit, and a "
"key both edited takes this page's value (the deliberate press)",
_both == {"a": 2, "b": 9, "c": 5, "d": 0}
and list(_both) == ["a", "b", "c", "d"])
_gone = tiers(setup_checks(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS))
check("a meter entity Home Assistant does not have is red on the last step, "
"so the wizard does not save it with the kettle test skipped",
("bad", "Home Assistant has no entity sensor.gone, which “Grid power "
"entity” (meter_entity) names.") in _gone)
_net_gone = tiers(setup_checks(dict(SITE, meter_source="ha_signed",
p1_net_entity="sensor.gone"),
entities=ROWS))
check("...for a net or import and export meter too",
[t for t, x in _net_gone if "sensor.gone" in x] == ["bad"])
from app.validate import config_notes # noqa: E402
check("...while the Installer view keeps it a warning, which never blocks "
"a save there",
[n.tier for n in config_notes(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS)
if "sensor.gone" in n.text] == ["warn"])
check("step 1 after a skip that followed a fall says plainly that the "
"reading went down",
'k.say === "skipped" && k.wrong ? "skipped_down" : k.say' in JS
and "went down" in WS["kettle"]["skipped_down"]["description"]
and "not tested" not in WS["kettle"]["skipped_down"]["description"])
check("in setup-only mode the wizard's restart dialog does not claim a 0 W "
"wind-down that nothing runs, and the shadow one says 0 W",
"M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live)" in JS
and "0 W" in WS["dialog_setup"]["description"]
and "0 W" not in WS["dialog_setup_live"]["description"])
check("the page posts the model it opened with every save (F2)",
"model: V, base: ORIG, fingerprint: FP" in JS)
print()
if fails:
print(f"{len(fails)} FAILED")