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
+11 -5
View File
@@ -533,10 +533,12 @@
if (input) input.focus({ preventScroll: true });
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves.
// refusal the page holds the new token, so a second press saves. `base` is
// the model the page opened: only what changed since then is written, onto
// what is stored now (UX-15 F2).
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
@@ -624,8 +626,10 @@
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn" }[k.say] || "bad";
const words = k.say ? WS.kettle[k.say] : k.before != null ? WS.kettle.before : null;
// A skip after the reading went down still says it went down (UX-15).
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
const busy = (b) => { b.disabled = true; };
@@ -715,7 +719,9 @@
if (!b) { toast(WS.check.failed.description); return; }
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
const shadow = shadowValue() === M.setup.shadow.on;
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
ask(dlg, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }