diff --git a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md index 540cbb0..310e5b6 100644 --- a/goodwe_controller/DECISIONS/UX-13-one-layout-table.md +++ b/goodwe_controller/DECISIONS/UX-13-one-layout-table.md @@ -66,6 +66,10 @@ What UX-02 made authoritative stays authoritative: to the existing `/config/validate` and `/config/save`, which turn it into options with `ui_to_options()` before the shipped validator runs. No route was added. +- P4: the setup wizard draws the same model by each field's `wizard` step. + Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks + are rules, so they are in `validate.py` (`setup_checks`). settings.js still + names no key. See [[UX-13-setup-wizard]]. ## 4. What this costs diff --git a/goodwe_controller/DECISIONS/UX-13-setup-wizard.md b/goodwe_controller/DECISIONS/UX-13-setup-wizard.md new file mode 100644 index 0000000..5d7387e --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-13-setup-wizard.md @@ -0,0 +1,101 @@ +# UX-13 P4 — the setup wizard, and when it opens by itself + +**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved +2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on +[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6. + +## 1. What the wizard is + +The Setup tab draws the Installer view's page model one step at a time: a +field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's +controls, `/config/validate` and `/config/save`. No route was added. +`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the +optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes +yet (OPT-11); it says they come later. + +A step moves on when no refusal is anchored to one of its shown fields +(`uipage.step_problems`). A refusal on a hidden field, a field on no step or +no field at all blocks the last step, which lists every refusal. + +## 2. When it opens by itself (`uipage.setup_needed`) + +The page opens `#setup` only when it is opened with no route, and only when +`/config/model` gives a reason: + +- **`no_power_request`**: the stored options name no inverter to control. +- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the + Store has no `setup_done` and no `auto` key, and control is not started. + `auto` is the key Start/Stop writes. The startup gate exits 78 before the + controller writes the Store, so a new install's first good boot is fresh. + +"Never started" alone is not enough: both TEST-23 add-ons have `auto_start` +off and no `auto` key, and one has run in shadow for weeks (review of PR #146, +R1). So a Store read from a file is an old site, and it is never sent to the +wizard. A new install that restarts before the page is opened is not sent +either, and its Setup tab still works. A site that runs control, or ever +started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes +`setup_done` (an ISO time) to the Store. A save from the Installer view does +not. + +**Not decided here, for the owner:** the add-on exits 78 before it serves the +page when the stored options name no power request entity, which is what a +brand-new install ships (config.yaml's `setpoint_entity: ""`). So on a truly +new install the installer fills in "Inverter setpoint entity" on the +Configuration tab first, and the wizard opens on the next page visit. +Serving a setup-only page from a refused configuration would change the CFG-01 +startup gate and UX-02 §4 ("the panel is not available when it is most +needed"), so it is left as an open question. + +## 3. The kettle test and the sign checks + +Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one +`hass.state()` read, in W) before and after a kettle is switched on. The +change, not the sign, decides: a rise of at least 500 W keeps `meter_invert` +off, a fall turns it on. For a net entity or import and export, a fall cannot +be fixed by an option, so the step is not passed and the page says what to +check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by +the page, so the test is skipped and Home shows the direction after the +restart. The installer may skip the test, because a cloud can fake a fall; +the last step then warns, and after a fall it says the reading went down. + +The battery and solar signs cannot be forced from the page, which never +commands an inverter. The last step reports each battery's reading with its +direction ("reads 800 W charging now, so check the inverter shows the same") +and warns when a solar entity reads below zero without its "reads negative" +switch. It also warns when meter + solar + battery, all known, add up to a +house using less than -300 W: a battery sensor reversed while giving power. +An idle or charging battery cannot be judged that way. + +## 4. The last step + +`validate.setup_checks()` sits beside the rules because it names option keys. +It reads nothing itself: `web.py` passes a Home Assistant entity list at most +5 s old, the running options, the running meter reading and today's price +count. Tiers are ok, info, warn and bad. A power request entity missing from +Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77) +and only the entity list can tell. So is Home Assistant not answering, since +the list is then unknown. The wizard does not save while anything is +bad. It then saves through P2's path (`setup: "done"`) and asks for the +restart as the second, confirmed request, with a dialog that names the 0 W. +The shadow switch is on by default and writes `shadow_mode: on`, which holds +every inverter at 0 W after a restart even if control is started. Off keeps the +stored value, or `auto` if that was `on`. The switch goes only on the model the +wizard posts, never into the page model the Installer view also saves. + +## 5. The stale save lists only what moved (P2 test, finding a) + +`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a +stale save the add-on compares that blob with what is stored now, so the banner +lists only the other surface's changes. `changed` (stored against the posted +blob) is unchanged for the classic page's "theirs → mine" list. When the +fingerprint was not handed out by this process, it falls back to `changed`. + +## 6. How it is held + +- test_setup.py: the steps and tables, the kettle logic (and the page's copy of + it), step blocking, the first-run rule on new and TEST-23 sites, the live + checks, the save through the P2 path, the stale fix, and the words. +- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new + install and three TEST-23 sites (88 accepted by the shipped validator). At P4 + all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78 + keys each. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4ba090b..d5367cc 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -3221,6 +3221,32 @@ action log (`Controller.events`). The page no longer shows that log, but the page and add `status` to its address. The action-log tables in this document refer to it.* +### Setup, for a new install + +The **Setup** tab walks an installer through six steps: the meter, the +inverters and batteries, solar (optional), prices, the peak limit, and a last +check. Each step shows the same fields as Settings, and nothing is saved until +the last step. + +- **The kettle test** on the meter step reads the meter, then reads it again + after you switch on a kettle. If the reading goes down, the add-on flips its + sign for you; with a net or import/export meter it tells you what to change. +- **The last step** runs the add-on's own start checks and reads Home + Assistant: the meter reading, that each power request entity exists, the + battery levels, and whether prices are known. A missing power request entity + is red, so it cannot be saved. +- **Save and restart** saves, then restarts the add-on. Every inverter is set + to 0 W while it restarts, and with "Start in shadow mode" on (the default) it + stays at 0 W until you turn shadow mode off under Settings. + +Setup opens by itself on a new install's first start, until Setup finishes or +control is started, and whenever the stored settings name no inverter to +control. A site that already ran before is never sent there. The add-on does +not start with no power request entity at all, so on a brand-new install fill +in "Inverter setpoint entity" on the Configuration tab first. + +*For installers: card UX-13, and `DECISIONS/UX-13-setup-wizard.md`.* + ## Status page The add-on page shows a banner at the top and a **Commissioning** list below diff --git a/goodwe_controller/app/haentities.py b/goodwe_controller/app/haentities.py index 695ef6f..4aa47d8 100644 --- a/goodwe_controller/app/haentities.py +++ b/goodwe_controller/app/haentities.py @@ -13,6 +13,7 @@ CACHE_S = 30.0 DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty." STALE_TEXT = "Home Assistant is not answering, so this list may be old." _POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW") +_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6} _ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh") @@ -28,6 +29,18 @@ def row(state: dict) -> dict | None: "device_class": attrs.get("device_class")} +def watts(r: dict | None): + """A power entity's reading in W, or None when it is not a number in a + power unit (the setup wizard's kettle test).""" + if not r or r.get("unit") not in _TO_W: + return None + try: + value = float(r.get("state")) + except (TypeError, ValueError): + return None + return value * _TO_W[r["unit"]] if value == value else None + + def matches(r: dict, kind: str) -> bool: """Is `r` what a picker of this kind offers first? `kind` is one of uilayout.ENTITY_KINDS; "" or "any" offers everything.""" @@ -54,13 +67,16 @@ class EntityCache: self.tried_at: float | None = None self._lock = asyncio.Lock() - async def get(self) -> tuple[dict | None, float | None]: - """(rows by entity id, age in s), or (None, None) if never read.""" + async def get(self, max_age: float = CACHE_S + ) -> tuple[dict | None, float | None]: + """(rows by entity id, age in s), or (None, None) if never read. + `max_age` is how old a try may be before HA is asked again: the setup + wizard's last step asks for a list at most a few seconds old.""" async with self._lock: # one HA read however many pickers ask now = self.clock() # A failed read also waits CACHE_S, so polls while HA is down do # not queue one 10 s read each behind this lock. - if self.tried_at is None or now - self.tried_at >= CACHE_S: + if self.tried_at is None or now - self.tried_at >= max_age: self.tried_at = now states = await self.hass.states() if states is not None: @@ -71,6 +87,14 @@ class EntityCache: return None, None return self.rows, now - self.read_at + async def reading(self, entity_id: str) -> dict: + """One entity read now, not from the list: the kettle test compares + two readings seconds apart, which a 30 s list cannot. A read only.""" + r = row(await self.hass.state(entity_id)) if entity_id else None + return {"entity_id": entity_id, "found": r is not None, + "state": r and r["state"], "unit": r and r["unit"], + "watts": watts(r)} + def peek(self) -> dict | None: """The cached rows if they are fresh, without asking Home Assistant.""" if self.read_at is None or self.clock() - self.read_at >= CACHE_S: diff --git a/goodwe_controller/app/selfconfig.py b/goodwe_controller/app/selfconfig.py index 7c56b78..5458253 100644 --- a/goodwe_controller/app/selfconfig.py +++ b/goodwe_controller/app/selfconfig.py @@ -131,11 +131,16 @@ class SaveRefused(Exception): own sentences, byte for byte; `changed` names the keys that moved. """ - def __init__(self, kind, problems=(), changed=(), stored=None): + def __init__(self, kind, problems=(), changed=(), stored=None, + moved=None): self.kind = kind self.problems = list(problems) self.changed = list(changed) self.stored = stored + # UX-13 P4: the keys the OTHER surface changed - stored-on-open + # against stored-now - when the caller knows what it opened. None + # when it does not. `changed` also holds the operator's own edits. + self.moved = None if moved is None else list(moved) super().__init__(f"save refused ({kind})") @@ -359,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None): async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *, - hass=None) -> dict: + hass=None, base=None) -> dict: """Validate, compare-and-swap, then write. Refuse rather than half-do. Returns `{"fingerprint": }` on success. Raises `SaveRefused` @@ -382,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *, genuinely wants what the other surface wrote replaced, the page reloads, shows them what changed, and they save again from THAT - which is a decision made in front of the difference rather than through it. + + `base`, when given, is the blob the page opened (the one `expect` is the + fingerprint of). A stale refusal then also names the keys that moved + underneath it, so the page does not list the operator's own edits among + the settings "changed elsewhere" (UX-13 P2 test, finding a). """ problems = await problems_for(proposed, hass) if problems: @@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *, actual = fingerprint(stored) if actual != expect: changed = changed_keys(stored, proposed) + moved = (changed_keys(base, stored) + if isinstance(base, dict) and fingerprint(base) == expect + else None) + keys = changed if moved is None else moved _LOG.warning("The settings page did not save, because the settings " "changed while it was open (keys: %s).", - ", ".join(changed) or "none this page also edited") - raise SaveRefused("stale", changed=changed, stored=stored) + ", ".join(keys) or "none this page also edited") + raise SaveRefused("stale", changed=changed, stored=stored, + moved=moved) await selfcfg.write_options(proposed) _LOG.info("options saved from the ingress panel; a restart is needed " diff --git a/goodwe_controller/app/static/app.css b/goodwe_controller/app/static/app.css index ad582d7..d766298 100644 --- a/goodwe_controller/app/static/app.css +++ b/goodwe_controller/app/static/app.css @@ -216,3 +216,11 @@ input:disabled{opacity:.6} #savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)} @media (max-width:640px){#savebar{bottom:62px}} #s-set{padding-bottom:80px} +/* UX-13 P4: the setup wizard */ +.steps{display:flex;gap:4px;margin:4px 0 12px} +.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)} +.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55} +.msg.ok{background:var(--ok-bg);color:var(--ok)} +.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)} +.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere} +.kettle .row{margin-top:8px} diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 0153c55..8c9530c 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -332,7 +332,9 @@ $("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort"))); // Shared with settings.js, which loads after this file. - window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) }; + // `first`: the page opened with no route, so settings.js may open the setup + // wizard on a new install (UX-13 P4). + window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash }; /* ---------------- tabs: hash routes, arrow keys ---------------- */ const TABS = [...document.querySelectorAll(".tab")]; diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index ad2b649..3831627 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -117,11 +117,18 @@ diff --git a/goodwe_controller/app/static/settings.js b/goodwe_controller/app/static/settings.js index 18273be..603dfcb 100644 --- a/goodwe_controller/app/static/settings.js +++ b/goodwe_controller/app/static/settings.js @@ -5,7 +5,9 @@ ranges and choices all arrive as data. It posts the page model back; the add-on turns it into options and runs its own start checks, and nothing they refuse is saved. Every word is from app/ui_text or the add-on, and - data is set as text, never as HTML. */ + data is set as text, never as HTML. + P4: the Setup tab's wizard is the same page model, drawn one step at a + time from each field's `wizard` step, and saved through the same routes. */ (function () { const ROOT = JSON.parse(document.getElementById("ui-text").textContent); const W = ROOT.settings; @@ -28,7 +30,8 @@ let DEVICE = ""; let LIST = null; // the inverter list field let FILLED = {}; // field key -> the preset field that fills it - let active = false; + let view = null; // "settings" or "setup": the tab drawing V + let STEPS = {}; // wizard step -> refusals that block it let uid = 0; const ROWS = []; const OPEN = new Set(); @@ -60,7 +63,7 @@ if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v; } if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value; - if (redraw || f.fills) render(); else paint(); + if (redraw || f.fills) redrawView(); else paint(); queueValidate(); } function findField(key) { @@ -290,6 +293,7 @@ /* ---------------- rows, cards and groups ---------------- */ function keyText(f, card) { + if (view === "setup") return ""; // the wizard shows no keys if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f); return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key; } @@ -324,7 +328,7 @@ } else { cards.forEach((c, i) => { const box = h("div", { class: "inv" }, h("div", { class: "hd" }, - h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"), + h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"), cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => { V[f.key].splice(i, 1); V[f.count] = V[f.key].length; // Back to one card, the stored shape returns: a flat inverter stays flat. @@ -393,7 +397,7 @@ return m.card == null ? r.card === 0 : m.card === r.card; } function paint() { - const q = $("#set-search").value.trim().toLowerCase(); + const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase(); const placed = new Set(); for (const r of ROWS) { r.visible = visible(r.f); @@ -451,7 +455,7 @@ } function savebar() { const n = V ? dirtyCount() : 0; - $("#savebar").hidden = !(active && n); + $("#savebar").hidden = !(view === "settings" && n); $("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many); } $("#set-search").addEventListener("input", () => { if (V) paint(); }); @@ -459,17 +463,21 @@ /* ---------------- talking to the add-on ---------------- */ let vtimer = null, vseq = 0; function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); } - async function validate() { + // `extra` asks for more, e.g. the wizard's live checks. Returns the answer, + // or null when it failed or a newer check overtook it. + async function validate(extra) { const seq = ++vseq; try { const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: V }) }); + body: JSON.stringify(Object.assign({ model: V }, extra || {})) }); const b = await r.json(); - if (seq !== vseq) return; + if (seq !== vseq) return null; MSG = b.messages || []; DEVICE = b.device_note || ""; + STEPS = b.steps || {}; paint(); - } catch (e) { /* the next change tries again */ } + return b; + } catch (e) { return null; /* the next change tries again */ } } function banner(level, title, sub, button, onClick) { const host = $("#set-banner"); @@ -513,7 +521,7 @@ if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart); else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart); else banner(); - render(); + if (view === "setup") drawStep(); else render(); validate(); } function firstBad() { @@ -524,6 +532,20 @@ const input = r.el.querySelector("input,select,button"); 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. + 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 || {})) }); + const b = await r.json().catch(() => ({})); + if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP; + return { r, b }; + } + // The settings another surface changed (not this page's own edits). + function staleText(b) { + const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; }); + return W.stale.description + (names.length ? " " + names.join(", ") + "." : ""); + } async function save() { const btn = $("#sb-save"); btn.disabled = true; btn.textContent = W.savebar.saving; @@ -531,14 +553,10 @@ clearTimeout(vtimer); await validate(); if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; } - const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: V, fingerprint: FP }) }); - const b = await r.json().catch(() => ({})); + const { r, b } = await postSave(); if (r.ok) { toast(W.toast.saved); await load("saved"); return; } if (r.status === 409 && b.kind === "stale") { - FP = b.fingerprint || FP; - const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; }); - banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : "")); + banner("bad", W.stale.name, staleText(b)); return; } if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; } @@ -552,10 +570,239 @@ await load(); toast(W.toast.discarded); }); addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } }); + + /* ---------------- the setup wizard (P4) ---------------- */ + const WS = W.setup; + const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null }; + function redrawView() { if (view === "setup") drawStep(); else render(); } + function allFields() { + const out = []; + for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f); + return out; + } + const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text); + function watts(w, signed) { + const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " "); + return (signed && w < 0 ? "-" : "") + n + " W"; + } + function line(tier, text) { + const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i"; + return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon), + h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text)); + } + + // Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is + // uipage.kettle() (test_setup.py grades that one) on the page. + function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; } + function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); } + function kettleOutcome(src, before, after) { + const d = after - before; + if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d }; + const o = src[d > 0 ? "up" : "down"]; + return { say: o.say, sets: o.sets, d }; + } + function kettleOk() { + const src = kettleSource(); + return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok); + } + async function readMeter(src) { + let total = 0; + for (const [key, sign] of src.read) { + if (!V[key]) return null; + const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" }) + .then((x) => (x.ok ? x.json() : null)).catch(() => null); + if (!r || r.watts == null) return null; + total += sign * r.watts; + } + return total; + } + function kettleBox() { + const src = kettleSource(); + const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name)); + if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; } + const sig = kettleSig(src); + 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; + 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; }; + const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read); + read.addEventListener("click", async () => { + busy(read); + const sig = kettleSig(src); // the entity may have changed since drawing + if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; } + const w = await readMeter(src); + if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" }); + else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong }; + else { + const o = kettleOutcome(src, k.before, w); + // `wrong`: the reading went the way no option can fix. A skip after + // that keeps saying so on the last step (review R2). + const wrong = o.say === "none" ? !!k.wrong : o.sets == null; + WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong }; + if (o.sets) { Object.assign(V, o.sets); queueValidate(); } + } + drawStep(); + }); + const row = h("div", { class: "row" }, read); + if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => { + WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep(); + } }, WS.kettle.skip)); + box.append(row); + return box; + } + + // Step 6: the shadow switch, the checks, and Save and restart. + // The shadow switch goes only on the model the wizard posts, never into V, + // which the Installer view saves too (review R3). + function shadowValue() { + const s = M.setup.shadow; + return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off); + } + function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); } + function shadowRow() { + const lid = "wzs"; + const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid }); + b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); }); + return h("div", { class: "f inline" }, + h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)), + h("div", { class: "ctl" }, b)); + } + async function runChecks() { + const list = $("#wz-checks"); + if (list) list.replaceChildren(line("info", WS.check.running.description)); + clearTimeout(vtimer); + const b = await validate({ setup: true, model: posted() }); + WZ.checked = b; + if (!list || !list.isConnected) return b; + if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; } + const items = [], seen = new Set(); + const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } }; + const bad = (b.messages || []).filter((m) => m.tier === "bad"); + if (!bad.length) add("ok", WS.check.pass.description); + for (const m of bad) add("bad", m.text); + for (const m of b.setup || []) add(m.tier, m.text); + if (b.device_note) add("info", b.device_note); + const src = kettleSource(); + if (!src) add("info", WS.kettle.self.description); + else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description); + else if (kettleOk()) add("ok", WS.kettle.tested.description); + const rank = { bad: 0, warn: 1, ok: 2, info: 3 }; + items.sort((a, z) => rank[a[0]] - rank[z[0]]); + list.replaceChildren(...items.map(([t, x]) => line(t, x))); + WZ.blocked = items.some(([t]) => t === "bad"); + return b; + } + function finalBox() { + const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again); + setTimeout(runChecks, 0); + return [h("div", { class: "subhead" }, WS.check.name), + h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again), + shadowRow()]; + } + function wzBanner(level, title, sub, button, onClick) { + const host = $("#wz-banner"); + if (!title) { host.replaceChildren(); return; } + host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") }, + h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"), + h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null)))); + } + async function finish() { + const b = await runChecks(); + 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 () => { + wzBanner(); + let res; + try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; } + const { r, b: body } = res; + if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; } + if (r.status === 422) { + MSG = body.messages || []; STEPS = body.steps || {}; + const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0); + if (first) WZ.step = first; + drawStep(); toast(W.toast.blocked.description); firstBad(); + return; + } + if (!r.ok) { toast(W.toast.failed); return; } + let restarted = true; + try { + const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "restart" }) }); + restarted = rr.ok; + } catch (e) { /* the add-on stops answering while it restarts */ } + WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed"; + drawStep(); + if (!restarted) await load("saved"); // still running: read what is stored + }); + } + function drawDone() { + const w = WS[WZ.done]; + $("#wz-count").textContent = ""; + $("#wz-steps").replaceChildren(); + $("#wz-desc").textContent = ""; + $("#wz-nav").hidden = true; + ROWS.length = 0; + if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart); + else wzBanner("ok", w.name, w.description); + $("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home)); + } + function drawStep() { + if (!M) return; + if (WZ.done) { drawDone(); return; } + const S = M.setup, n = WZ.step, words = WS.steps[n]; + $("#wz-nav").hidden = false; + $("#wz-title").textContent = words.name; + $("#wz-count").textContent = say(WS.step, { n, total: S.steps }); + $("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) => + h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null }))); + $("#wz-desc").textContent = words.description; + ROWS.length = 0; + const body = []; + if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description)); + for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz")); + if (n === 1) body.push(kettleBox()); + if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description)); + if (n === S.final) body.push(...finalBox()); + $("#wz-body").replaceChildren(...body); + $("#wz-back").textContent = n === 1 ? WS.not_now : WS.back; + $("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next; + paint(); + } + $("#wz-back").addEventListener("click", () => { + if (WZ.step === 1) { location.hash = "#home"; return; } + WZ.step -= 1; drawStep(); window.scrollTo(0, 0); + }); + $("#wz-next").addEventListener("click", async () => { + if (!M) return; + const btn = $("#wz-next"); + btn.disabled = true; + try { + const n = WZ.step; + if (n === M.setup.final) { await finish(); return; } + clearTimeout(vtimer); + const b = await validate(); + if (!b) { toast(W.toast.failed); return; } + if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; } + if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; } + WZ.step = n + 1; drawStep(); window.scrollTo(0, 0); + } finally { btn.disabled = false; } + }); + document.addEventListener("ux13:tab", (e) => { - active = e.detail === "settings"; - if (active && !M) load(); else savebar(); + view = e.detail === "settings" || e.detail === "setup" ? e.detail : null; + if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar(); }); // app.js chose the first tab before this file ran. - if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); } + const sel = document.querySelector(".tab[aria-selected=true]"); + if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); } + // First run: opened with no route, on a site the add-on says is new + // (uipage.setup_needed), the page goes to Setup by itself. + else if (window.UX13.first) { + load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; }); + } })(); diff --git a/goodwe_controller/app/store.py b/goodwe_controller/app/store.py index 22cfd7b..b3fea7a 100644 --- a/goodwe_controller/app/store.py +++ b/goodwe_controller/app/store.py @@ -585,6 +585,9 @@ class Store: def __init__(self, path: str = "/data/state.json"): self.path = path self.data = dict(DEFAULTS) + # UX-13 P4: True when this boot found no state file, i.e. a new /data. + # Only the setup wizard's first-run rule reads it (uipage.setup_needed). + self.fresh = False self.load() def load(self) -> None: @@ -598,6 +601,7 @@ class Store: self.data.get("phase"), self.data.get("last_completed"), self.data.get("sign_tripped")) except FileNotFoundError: + self.fresh = True _LOG.info("no saved state, starting fresh") except (json.JSONDecodeError, OSError) as err: # A corrupt state file must not stop the controller: losing the diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index dbb87a0..bd5cb3c 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -80,8 +80,7 @@ "toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."}, "offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."}, "batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"}, - "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}, - "setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."} + "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"} }, "choices_later": { "region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"} @@ -148,7 +147,7 @@ "blocked": {"description": "Fix the settings marked in red, then save again."}, "restarting": {"description": "Restart requested, so this page stops answering for a moment."} }, - "tier": {"bad": "Must fix", "warn": "Check this", "info": "Note"}, + "tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"}, "derive": { "raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."}, "no_inverter": {"description": "Add at least one inverter."}, @@ -157,6 +156,60 @@ "custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."}, "no_price": {"description": "Enter a price per kWh."}, "unknown": {"description": "The page cannot store this choice, so reload the page."} + }, + "setup": { + "name": "Setup", + "description": "For installers: six steps to a working site, and each can be changed later under Settings.", + "done_before": {"description": "This site is set up already, so a change here replaces its stored settings."}, + "step": "Step {n} of {total}", + "back": "Back", + "next": "Next", + "skip": "Next, or skip", + "finish": "Save and restart", + "not_now": "Not now", + "steps": { + "1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."}, + "2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."}, + "3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."}, + "4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."}, + "5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."}, + "6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."} + }, + "modes_later": {"description": "Choosing a mode for each battery comes in a later update."}, + "kettle": { + "name": "Check the direction", + "description": "Read the meter, switch on a kettle, then read the meter again.", + "read": "Read the meter", + "again": "Read again", + "skip": "Skip this test", + "before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."}, + "unread": {"description": "The meter cannot be read now, so check the entity and try again."}, + "none": {"description": "The reading moved only {d}, so switch the kettle on and read again."}, + "ok": {"description": "Good, the reading went up by {d} when the kettle came on."}, + "flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."}, + "wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."}, + "wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."}, + "self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."}, + "skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."}, + "skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."}, + "tested": {"description": "The meter direction was tested with a kettle."}, + "needed": {"description": "Do the kettle test first, or skip it."} + }, + "check": { + "name": "What the add-on checked", + "running": {"description": "Checking with the add-on's own start checks."}, + "pass": {"description": "The add-on's start checks pass."}, + "failed": {"description": "The check did not run, so try again."}, + "again": "Check again", + "blocked": {"description": "Fix the items marked in red, then check again."} + }, + "shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."}, + "dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"}, + "dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"}, + "done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."}, + "done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."}, + "restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."}, + "home": "Open Home" } } } diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index b0288e4..4eafd2e 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -234,6 +234,34 @@ GROUPS = ( ), support_only=True), ) +# --------------------------------------------------------------------------- # +# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this +# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md). +# --------------------------------------------------------------------------- # +WIZARD_STEPS = 6 +FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here +OPTIONAL_STEPS = (3,) # solar: "Next, or skip" + +# Step 1's kettle test. Per meter source Home Assistant reads: the page fields +# read before and after a kettle is switched on, each with the sign it adds to +# the grid reading, and what each direction means as (outcome, fields it +# sets). Sets None: the options cannot fix it, so the step is not passed. A +# source not listed is read by the add-on itself, so the page cannot test it. +KETTLE_SOURCE = "meter_source" +KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less +KETTLE = { + "off": ((("meter_entity", 1),), + {"up": ("ok", {"meter_invert": False}), + "down": ("flip", {"meter_invert": True})}), + "ha_signed": ((("p1_net_entity", 1),), + {"up": ("ok", {}), "down": ("wrong_signed", None)}), + "ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)), + {"up": ("ok", {}), "down": ("wrong_dsmr", None)}), +} +# Step 6 starts the site in shadow mode unless the installer switches it off: +# (field, value when on, value when off). +SHADOW = ("shadow_mode", "on", "auto") + # Option keys the page never shows. uimodel.py writes each from page fields. DERIVED = { "setpoint_entity": "inverters", # one inverter, stored flat diff --git a/goodwe_controller/app/uipage.py b/goodwe_controller/app/uipage.py index d82c4fa..42dfd52 100644 --- a/goodwe_controller/app/uipage.py +++ b/goodwe_controller/app/uipage.py @@ -17,6 +17,7 @@ import re from . import uilayout as L from . import uimodel as U from .configui import help_of, label_of, problems_by_field, split_group +from .validate import power_request_entities UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text", "en.json") @@ -101,6 +102,7 @@ def _field(fld, schema_ui, translations, lang, T): "help": text, "unit": fld.unit, "scale": fld.scale, "step": fld.step, "kind": fld.kind, "show": [list(c) for c in fld.show], "applies": fld.applies, + "wizard": fld.wizard, "type": value_type, "optional": not node.get("required", False), # A choice mirrored into a field also shows (and finds) its keys. "writes": sorted(k for k, how in L.DERIVED.items() @@ -278,3 +280,78 @@ def fields_of(keys, model) -> list: """The page fields that show these option keys (a stale save's list).""" return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model) if f)) + + +# --------------------------------------------------------------------------- # +# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md) +# --------------------------------------------------------------------------- # +# Store keys (app/store.py), not options. SETUP_DONE is written when the +# wizard saves. STARTED is the one control's Start and Stop write. +SETUP_DONE = "setup_done" +STARTED = "auto" + + +def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None: + """Why the page opens the wizard by itself, or None. + + "no_power_request": the stored options name no inverter to control, so + the add-on cannot start on them. "first_start": a new install. This boot + found no Store file (`fresh`), the wizard never saved and control was never + started or stopped. `fresh` is what tells a new install from an old site + that was never started, e.g. one left in shadow for weeks (TEST-23). + `store_data` is the Store's dict (None: unknown, so no), `started` is + whether control is started now.""" + if not power_request_entities(stored or {}): + return "no_power_request" + if store_data is None or not fresh: + return None + if store_data.get(SETUP_DONE) or STARTED in store_data or started: + return None + return "first_start" + + +def setup_spec() -> dict: + """What the wizard needs beyond each field's step: read off the table.""" + kettle = {} + for source, (read, outcomes) in L.KETTLE.items(): + kettle[source] = {"read": [[f, sign] for f, sign in read]} + for way, (say, sets) in outcomes.items(): + kettle[source][way] = {"say": say, "sets": sets} + field, on, off = L.SHADOW + return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP, + "optional": list(L.OPTIONAL_STEPS), + "kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W, + "sources": kettle}, + "shadow": {"field": field, "on": on, "off": off}} + + +def kettle(source, before_w, after_w): + """The kettle test's verdict, as the page reaches it (settings.js + `kettleOutcome`): (outcome, fields it sets or None, change in W), or None + for a source the page cannot read.""" + spec = L.KETTLE.get(source) + if spec is None: + return None + change = float(after_w) - float(before_w) + if abs(change) < L.KETTLE_MIN_W: + return "none", None, change + say, sets = spec[1]["up" if change > 0 else "down"] + return say, (dict(sets) if sets is not None else None), change + + +def step_of(field_key, model) -> int: + """The wizard step a sentence on this page field blocks: the field's own + step while it is shown, else the last step, which lists everything.""" + fld = L.field(field_key) if field_key else None + if fld is None or not fld.wizard or not L.visible(fld, model): + return L.FINAL_STEP + return fld.wizard + + +def step_problems(messages, model) -> dict: + """{"1": n, ...}: how many refusals block each wizard step.""" + out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)} + for m in messages: + if m.get("tier") == "bad": + out[str(step_of(m.get("field"), model))] += 1 + return out diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 5177739..2a27f6d 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -374,7 +374,8 @@ class Note(NamedTuple): """UX-13: a warning or info for the settings page. Never a refusal: the add-on starts. `keys` are the options it is about, so the page can show it beside them.""" - tier: str # "warn" or "info" + tier: str # "warn" or "info"; the wizard's checks + # also "ok" and "bad" (setup_checks) keys: tuple text: str @@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None, """ notes: list[Note] = [] if entities is not None: - for key, where, entity in _entities_read(opts): - row = entities.get(entity) - if row is None: - _advise(notes, "warn", (key,), - f"Home Assistant has no entity {entity}, which " - f"{S(key, where)} names.") - continue - unit = str(row.get("unit") or "") - if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W": - _advise(notes, "warn", (key,), - f"{entity} reads in {unit}, but {S(key, where)} is " - "taken as watts, so pick a sensor in W.") + _entity_notes(notes, opts, entities) cap = _number(opts, "peak_cap_w", None) if month_peak_w is not None and cap is not None and cap < month_peak_w: _advise(notes, "warn", ("peak_cap_w",), @@ -1769,6 +1759,256 @@ def config_notes(opts: dict, *, entities: dict | None = None, return notes +def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None: + """A note for each entity these options read that Home Assistant does not + have, or that reads in kW where watts are taken. A missing entity whose + key is in `bad` is a "bad" note, which blocks the setup wizard.""" + for key, where, entity in _entities_read(opts): + row = entities.get(entity) + if row is None: + _advise(notes, "bad" if key in bad else "warn", (key,), + f"Home Assistant has no entity {entity}, which " + f"{S(key, where)} names.") + continue + unit = str(row.get("unit") or "") + if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W": + _advise(notes, "warn", (key,), + f"{entity} reads in {unit}, but {S(key, where)} is " + "taken as watts, so pick a sensor in W.") + + +# --------------------------------------------------------------------------- # +# UX-13 P4: the setup wizard's last step +# --------------------------------------------------------------------------- # +# Below this a battery or solar reading counts as idle for a sign check. +IDLE_W = 50.0 +# Meter + solar + battery is the house's use, never below zero. Below minus +# this, read seconds apart, a direction is reversed (a battery giving power). +BALANCE_W = 300.0 +# The keys that pick the meter. A meter the add-on reads itself (HomeWizard, +# MQTT) is only known live when the running options read the same one. +_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic") + + +def power_request_entities(opts: dict) -> list: + """The power request entity of each inverter these options name: the flat + one, or each entry's. Blank ones are left out.""" + entries = roster_entries(opts) + if not entries: + values = [opts.get("setpoint_entity")] + else: + values = [e.get("setpoint_entity") for e in entries + if isinstance(e, dict)] + return [str(v).strip() for v in values + if isinstance(v, str) and v.strip()] + + +def _reading(entities, entity, convert=False): + """(number, unit) of an entity's state, or (None, state text). The number + is as the add-on reads it: raw for the meter and battery power, which it + takes as watts, and in W for solar (`convert`), which pv.py converts.""" + row = (entities or {}).get(entity) or {} + unit = str(row.get("unit") or "") + try: + value = float(row.get("state")) + except (TypeError, ValueError): + return None, str(row.get("state") or "nothing") + if value != value or value in (float("inf"), float("-inf")): + return None, str(row.get("state")) + return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit + + +def _signed(w) -> str: + return ("-" if w < 0 else "") + messages.watts(w) + + +def _cards(opts: dict) -> list: + """(who, entry) per inverter: who is " for " in a list, else "".""" + entries = roster_entries(opts) + if not entries: + return [("", {k: opts.get(k) for k in ( + "setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})] + out = [] + for i, entry in enumerate(entries): + if isinstance(entry, dict): + name = _unit_label(opts, i).strip().strip("()") + out.append((f" for {name}" if name else "", entry)) + return out + + +def setup_checks(opts: dict, *, entities: dict | None, running=None, + meter_now_w=None, prices_known=None) -> list[Note]: + """UX-13 P4: what the setup wizard's last step shows, beside the refusals. + + Live checks on the proposed options: the meter reads, each power request + entity exists in Home Assistant (a write to a missing one is answered 200, + SAFETY-77, so only the entity list can tell), the battery levels read, and + prices are known. Tier "ok", "info", "warn", or "bad" for a missing power + request entity, which the wizard will not save. Reads nothing itself: + `entities` is Home Assistant's list by id (None when it did not answer), + `running` the options this process started with, `meter_now_w` its meter + reading and `prices_known` how many of today's hours have a price. + """ + notes: list[Note] = [] + if entities is None: + # Red: only the list can tell a missing power request entity (SAFETY-77). + _advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not " + "answering, so press Check again once it is back.") + else: + _entity_notes(notes, opts, entities, bad=("setpoint_entity",)) + meter = _meter_check(notes, opts, entities, running, meter_now_w) + batts = [_card_checks(notes, who, entry, entities) + for who, entry in _cards(opts)] + solar = [] + if entities is not None: + flipped = pv.inverted(opts) + for entity in pv.entities(opts): + w, _unit = _reading(entities, entity, convert=True) + if entity not in entities or w is None: + solar.append(None) + continue + if w < -IDLE_W and entity not in flipped: + solar.append(None) + _advise(notes, "warn", ("pv_invert_entities",), + f"{entity} reads below zero now, so turn on its " + "“Reads negative” switch in the Solar step.") + else: + w = -w if entity in flipped else w + solar.append(w) + _advise(notes, "ok", ("pv_entity",), + f"{entity} reads {_signed(w)} of solar production " + "now.") + # A battery sensor that reads the wrong way while giving power makes the + # house use less than nothing. Only with every term known: a missing + # solar reading could be the difference. + known = [meter] + batts + solar + if solar and batts and all(w is not None for w in known) \ + and sum(known) < -BALANCE_W: + _advise(notes, "warn", ("batt_invert",), + f"The readings add up to {_signed(sum(known))} of house " + "use, so check each " + "battery's “Battery power sign is reversed” switch in step 2.") + _price_check(notes, opts, running, prices_known) + return notes + + +def _meter_check(notes, opts, entities, running, meter_now_w): + """The meter line. Returns the grid reading in W (positive: taken from + the grid) as the add-on will read it, or None when it is not known.""" + source = str(opts.get("meter_source") or "off").strip() + if source == "off": + parts = [(opts.get("meter_entity"), 1.0)] + elif source == SOURCE_HA_SIGNED: + parts = [(opts.get("p1_net_entity"), 1.0)] + elif source == SOURCE_HA: + parts = [(opts.get("p1_import_entity"), 1.0), + (opts.get("p1_export_entity"), -1.0)] + else: + same = isinstance(running, dict) and all( + running.get(k) == opts.get(k) for k in _METER_KEYS) + if same and meter_now_w is not None: + _meter_line(notes, float(meter_now_w)) + return float(meter_now_w) + _advise(notes, "info", ("meter_source",), "The add-on reads " + "this meter itself, so Home shows its reading after " + "the restart.") + return None + if entities is None: + return None + total = 0.0 + for entity, sign in parts: + entity = str(entity or "").strip() + if not entity or entity not in entities: + return None # a refusal or a missing-entity note says so + w, text = _reading(entities, entity) + if w is None: + _advise(notes, "warn", ("meter_source",), + f"{entity} reads {text} now, so check it in Home " + "Assistant before you start.") + return None + total += sign * w + if source == "off" and opts.get("meter_invert"): + total = -total + _meter_line(notes, total) + return total + + +def _meter_line(notes, w) -> None: + if abs(w) < 1.0: + text = "The meter reads 0 W now." + elif w > 0: + text = f"The meter reads {messages.watts(w)} taken from the grid now." + else: + text = f"The meter reads {messages.watts(w)} fed into the grid now." + _advise(notes, "ok", ("meter_source",), text) + + +def _card_checks(notes, who, entry, entities): + """The checks for one inverter's card. Returns its battery power in W, + positive while giving power, or None when it is not known.""" + if entities is None: + return None + setpoint = str(entry.get("setpoint_entity") or "").strip() + if setpoint and setpoint in entities: + _advise(notes, "ok", ("setpoint_entity",), + f"Home Assistant has {setpoint}, the power request " + f"entity{who}.") + soc = str(entry.get("soc_entity") or "").strip() + if not soc: + _advise(notes, "warn", ("soc_entity",), f"There is no battery " + f"level entity{who}, so pick one in step 2 or the add-on " + "holds 0 W.") + elif soc in entities: + level, _unit = _reading(entities, soc) + if level is None or not 0.0 <= level <= 100.0: + _advise(notes, "warn", ("soc_entity",), f"The battery " + f"level{who} reads nothing now, so check {soc}.") + else: + _advise(notes, "ok", ("soc_entity",), + f"The battery level{who} reads {level:.0f} %.") + batt = str(entry.get("batt_entity") or "").strip() + if batt and batt in entities: + w, _unit = _reading(entities, batt) + if w is None: + _advise(notes, "warn", ("batt_entity",), f"The battery " + f"power{who} reads nothing now, so check {batt}.") + return None + w = -w if entry.get("batt_invert") else w + if abs(w) < IDLE_W: + _advise(notes, "info", ("batt_entity",), f"The battery{who} is " + "idle now, so check its direction on Home later.") + else: + doing = "giving power" if w > 0 else "charging" + _advise(notes, "info", ("batt_invert",), + f"The battery{who} reads {messages.watts(w)} {doing} " + "now, so check the inverter shows the same.") + return w + return None + + +def _price_check(notes, opts, running, prices_known) -> None: + provider = str(opts.get("price_provider") or "off").strip() + profile = str(opts.get("static_profile") or "").strip() + if provider in ("", "off"): + if profile: + _advise(notes, "ok", ("static_profile",), + "Your prices come from your contract, so they are " + "always known.") + else: + _advise(notes, "info", ("price_provider",), + "No prices are set, so the batteries do not charge " + "on price.") + return + same = (isinstance(running, dict) + and str(running.get("price_provider") or "") == provider) + if same and prices_known: + _advise(notes, "ok", ("price_provider",), + f"Prices for today are in, for {prices_known} of 24 hours.") + else: + _advise(notes, "info", ("price_provider",), + "Prices are fetched after the restart, and Home shows them.") + + def _number(opts: dict, key, default): raw = opts.get(key, default) if raw is None or raw == "": diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index e599ce0..730073a 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -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,12 @@ 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)), + fresh=getattr(getattr(controller, "store", None), "fresh", + False) is True), }) return web.json_response(model) @@ -213,8 +239,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 +294,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 +322,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 +331,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 +368,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() diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index f75653d..fe882ca 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -920,7 +920,7 @@ DATA = { "price_history.py:messages.count(len(points), 'price')", "price_history.py:err", "selfconfig.py:messages.count(len(problems), 'problem')", - "selfconfig.py:', '.join(changed) or 'none this page also edited'", + "selfconfig.py:', '.join(keys) or 'none this page also edited'", "static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw", "tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month", # validate.py: option values and keys (the refusals are part 3's) diff --git a/goodwe_controller/test_settings.py b/goodwe_controller/test_settings.py index 5df6a2f..0df8da0 100644 --- a/goodwe_controller/test_settings.py +++ b/goodwe_controller/test_settings.py @@ -429,9 +429,11 @@ async def _stale(s, root, sup): _r, _sup = asyncio.run(session(TWO, _stale)) -check("409 with the page fields to look at, and nothing written", +check("409 with the page fields to look at, and nothing written. Only the " + "field the other surface changed, not the page's own edit (P4, P2 " + "test finding a)", _r[0] == 409 and _r[1]["kind"] == "stale" - and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == [] + and _r[1]["changed_fields"] == ["peak_cap_w"] and _sup.writes == [] and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options)) # --------------------------------------------------------------------------- # diff --git a/goodwe_controller/test_setup.py b/goodwe_controller/test_setup.py new file mode 100644 index 0000000..be86f82 --- /dev/null +++ b/goodwe_controller/test_setup.py @@ -0,0 +1,764 @@ +"""UX-13 P4: the setup wizard. `python3 test_setup.py` + +The wizard is the Installer view's page model drawn one step at a time from +each field's `wizard` step (app/uilayout.py), saved through the same routes. +Part 1 checks the steps and the tables the page reads. Part 2 the kettle +test's sign logic. Part 3 which refusals block which step. Part 4 the +first-run rule on new and existing sites. Part 5 the last step's live checks. +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. +Mutating a module this file grades? Read MUTATION.md first (purge __pycache__). +""" + +import asyncio +import copy +import io +import json +import logging +import os +import re +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.WARNING) # the validator's own log lines + +from aiohttp import ClientSession, web as aioweb # noqa: E402 + +import translations_lint as TL # noqa: E402 +import ux13_demo as D # noqa: E402 +import ux13_wizard_blobs as B # noqa: E402 +from app import uilayout as L, uimodel as U, uipage, web # noqa: E402 +from app.validate import setup_checks, validate_startup_config # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +SCHEMA = D.schema_ui(D.CONFIG) +TRANSLATIONS = {"en": TL.parse_translation_file( + os.path.join(HERE, "translations", "en.yaml"))} +DEFAULTS = D.defaults(D.CONFIG) +NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power") +UI_TEXT = uipage.ui_text() +WS = UI_TEXT["settings"]["setup"] +JS = io.open(os.path.join(HERE, "app", "static", "settings.js"), + encoding="utf-8").read() + +# --------------------------------------------------------------------------- # +print("1. six steps, drawn from the layout table") +# --------------------------------------------------------------------------- # +LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS) +TOP = [f for g in LAYOUT["groups"] for p in g["parts"] for f in p["fields"]] +BY_STEP = {n: [f["key"] for f in TOP if f["wizard"] == n] + for n in range(1, L.WIZARD_STEPS + 1)} +print(f" {BY_STEP}") +check("every field reaches the page with its wizard step", + all("wizard" in f for f in TOP) + and all(s["wizard"] == 2 for f in TOP for s in f.get("fields", []))) +check("the mockup's six steps: meter, inverters, solar, prices, peak limit, " + "check and start", + BY_STEP[1][0] == "meter_source" and BY_STEP[2] == ["inverters"] + and BY_STEP[3] == ["ui.solar"] and BY_STEP[4][0] == "ui.contract" + and BY_STEP[5] == ["peak_cap_w", "peak_forecast_entity"] + and BY_STEP[6] == [L.SHADOW[0]] + and [WS["steps"][str(n)]["name"] for n in range(1, 7)] + == ["Your meter", "Inverters and batteries", "Solar", "Prices", + "Peak limit", "Check and start"]) +check("battery modes are not a step yet (OPT-11): step 5 says they come later", + "later update" in WS["modes_later"]["description"] + and not any("mode" in k for k in BY_STEP[5])) +SPEC = uipage.setup_spec() +check("the spec the page reads is the table's", + SPEC["steps"] == 6 and SPEC["final"] == 6 and SPEC["optional"] == [3] + and SPEC["kettle"]["min_w"] == L.KETTLE_MIN_W + and set(SPEC["kettle"]["sources"]) == set(L.KETTLE) + and SPEC["shadow"] == {"field": "shadow_mode", "on": "on", + "off": "auto"}) +_src = L.field(L.KETTLE_SOURCE) +_choices = {c[0] for f in TOP if f["key"] == L.KETTLE_SOURCE + for c in f["choices"]} +_bad = [] +for source, (read, outcomes) in L.KETTLE.items(): + model = {L.KETTLE_SOURCE: source} + for key, _sign in read: + fld = L.field(key) + if not (fld and fld.wizard == 1 and fld.control == L.ENTITY + and L.visible(fld, model)): + _bad.append(key) + for _say, sets in outcomes.values(): + for key in (sets or {}): + fld = L.field(key) + if not (fld and fld.wizard == 1 and L.visible(fld, model)): + _bad.append(key) +check(f"each kettle source is a meter choice, and reads and sets only step-1 " + f"fields shown for it ({_bad})", + set(L.KETTLE) <= _choices and not _bad and _src.wizard == 1) +_shadow = next((f for f in TOP if f["key"] == L.SHADOW[0]), {}) +check("the shadow switch writes two of shadow mode's own choices", + {L.SHADOW[1], L.SHADOW[2]} <= {c[0] for c in _shadow.get("choices", [])}) + +# --------------------------------------------------------------------------- # +print("\n2. the kettle test's sign logic") +# --------------------------------------------------------------------------- # +K = uipage.kettle +check("one grid entity: up keeps the sign, down flips it", + K("off", -10, 1990) == ("ok", {"meter_invert": False}, 2000) + and K("off", 400, -1600) == ("flip", {"meter_invert": True}, -2000)) +check("the direction is the change, not the sign: exporting 3 kW, a kettle " + "still moves the reading up", + K("off", -3000, -1000)[0] == "ok" and K("off", 3000, 1000)[0] == "flip") +check(f"a change under {L.KETTLE_MIN_W:.0f} W sets nothing and is not passed", + K("off", 0, 499)[:2] == ("none", None) + and K("off", 0, -499)[:2] == ("none", None) + and K("off", 0, 500)[0] == "ok") +check("a net entity that goes down cannot be fixed by an option, so the step " + "is not passed", + K("ha_signed", 0, 2000) == ("ok", {}, 2000) + and K("ha_signed", 0, -2000)[:2] == ("wrong_signed", None)) +check("import and export are read as their difference", + L.KETTLE["ha_dsmr"][0] == (("p1_import_entity", 1), + ("p1_export_entity", -1)) + and K("ha_dsmr", -3000, -1000)[0] == "ok" + and K("ha_dsmr", 0, -2000)[0] == "wrong_dsmr") +check("a meter the add-on reads itself has no kettle test on the page", + K("homewizard_local", 0, 2000) is None and K("mqtt_p1", 0, 2000) is None) +_js = re.search(r"function kettleOutcome\(src, before, after\) \{(.*?)\n \}", + JS, re.S) +check("the page reaches the same verdict: settings.js kettleOutcome() is " + "uipage.kettle() line for line", + _js and "const d = after - before;" in _js.group(1) + and "Math.abs(d) < M.setup.kettle.min_w" in _js.group(1) + and 'src[d > 0 ? "up" : "down"]' in _js.group(1)) +check("...and it applies what the verdict sets, and passes the step only on " + "a verdict that sets something", + "if (o.sets) { Object.assign(V, o.sets); queueValidate(); }" in JS + and "ok: o.sets != null" in JS) +check("a changed meter or entity forgets the result, so the test runs again", + "if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null;" in JS) +check("each verdict has words", all(say in WS["kettle"] for _r, o in + L.KETTLE.values() + for say, _s in o.values()) + and {"none", "unread", "skipped", "self"} <= set(WS["kettle"])) + +# --------------------------------------------------------------------------- # +print("\n3. which refusals block which step") +# --------------------------------------------------------------------------- # +_m = U.options_to_ui(dict(NEW, meter_source="ha_signed")) +_msgs = [{"field": "p1_net_entity", "card": None, "tier": "bad", "text": "a"}, + {"field": "meter_entity", "card": None, "tier": "bad", "text": "b"}, + {"field": "inverters.setpoint_entity", "card": 0, "tier": "bad", + "text": "c"}, + {"field": "maintenance_charge_w", "card": None, "tier": "bad", + "text": "d"}, + {"field": None, "card": None, "tier": "bad", "text": "e"}, + {"field": "peak_cap_w", "card": None, "tier": "warn", "text": "f"}] +check("a refusal blocks the step of its field, a hidden or non-wizard field's " + "and an unplaced one the last step, and a warning blocks nothing", + uipage.step_problems(_msgs, _m) + == {"1": 1, "2": 1, "3": 0, "4": 0, "5": 0, "6": 3}) + + +class Store: + """app/store.py's shape: a dict under .data, and set().""" + + def __init__(self, data=None, fresh=False): + self.data = dict(data or {}) + self.fresh = fresh + + def set(self, key, value): + self.data[key] = value + + +def state(eid, value, unit="W", cls=None): + return {"entity_id": eid, "state": value, + "attributes": {"friendly_name": eid, "unit_of_measurement": unit, + "device_class": cls}} + + +class Hass: + session = None + token = "" + + def __init__(self, states): + self.states_now = states + self.reads = [] + + async def limits(self, _e): + return (-5000.0, 5000.0, 1.0) + + async def states(self): + return copy.deepcopy(self.states_now) + + async def state(self, entity): + self.reads.append(entity) + return next((copy.deepcopy(s) for s in self.states_now + if s["entity_id"] == entity), None) + + +class Supervisor: + def __init__(self, options): + self.options = copy.deepcopy(options) + self.writes = [] + self.restarts = 0 + + async def info(self): + return {"options": copy.deepcopy(self.options), "schema": SCHEMA, + "translations": TRANSLATIONS} + + async def write_options(self, options): + self.writes.append(copy.deepcopy(options)) + self.options = copy.deepcopy(options) + + async def restart(self, confirm): + self.restarts += 1 + + +class Controller: + def __init__(self, opts, states, store=None, auto=False, grid=None): + self.o, self.hass, self.inverters = dict(opts), Hass(states), [] + self.store, self.auto, self.grid = store, auto, grid + + def log_event(self, text, hint=None): + pass + + def status_payload(self): + return {"banner": "", "level": "ok", "rows": [], "checks": [], + "plan": {"prices": [{"eur_kwh": 0.2}] * 20 + + [{"eur_kwh": None}] * 4}} + + async def handle_action(self, what): + raise AssertionError(f"the wizard reached {what!r}") + + +async def session(ctl, stored, steps): + sup = Supervisor(stored) + runner = aioweb.AppRunner(web.build_app(ctl, sup)) + await runner.setup() + site = aioweb.TCPSite(runner, "127.0.0.1", 0) + await site.start() + root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/" + try: + async with ClientSession() as s: + out = await steps(s, root, sup) + finally: + await runner.cleanup() + return out, sup + + +async def _get(s, url): + async with s.get(url) as r: + return await r.json() + + +async def _post(s, url, body): + async with s.post(url, json=body) as r: + return r.status, await r.json() + + +STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"), + state("sensor.inv1_level", "62", "%", "battery"), + state("sensor.inv1_battery", "-800"), + state("sensor.pv", "-1200", "W", "power"), + state("sensor.net", "-3.1", "kW", "power")] + + +async def _steps(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + model = copy.deepcopy(m["ui"]) + model["inverters"][0]["setpoint_entity"] = "" + return (await _post(s, root + "config/validate", {"model": model}))[1] + + +_r, _sup = asyncio.run(session(Controller(NEW, STATES), NEW, _steps)) +print(f" blank power request entity: steps {_r['steps']}") +check("over HTTP, a blank power request entity blocks step 2 and no other", + _r["steps"]["2"] >= 1 + and all(v == 0 for k, v in _r["steps"].items() if k != "2")) +check("the page moves on only when the step's count is 0, the kettle test is " + "done on step 1, and the last step saves only with nothing red", + "if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); " + "firstBad(); return; }" in JS + and "if (n === 1 && !kettleOk())" in JS + and "if (WZ.blocked) { toast(WS.check.blocked.description); return; }" + in JS) + +# --------------------------------------------------------------------------- # +print("\n4. first run: a new install opens the wizard, a working site not") +# --------------------------------------------------------------------------- # +S = uipage.setup_needed +check("a new install: no Store file at this boot, never set up, control " + "never started or stopped", + S(NEW, {}, False, fresh=True) == "first_start" + and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True) + == "first_start") +check("a site that runs control, or ever ran or stopped it, is not sent to " + "the wizard", S(NEW, {}, True, fresh=True) is None + and S(NEW, {"auto": True}, False, fresh=True) is None + and S(NEW, {"auto": False}, False, fresh=True) is None) +check("a site the wizard saved is not sent again", + S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False, + fresh=True) is None) +# Review R1: the Stores the two TEST-23 add-ons really have (2026-09-28): no +# `auto` (Start/Stop never pressed, auto_start off), no setup_done, stopped, +# one of them in shadow for weeks. A Store read from a file is an old site. +_T23_ADDON = dict.fromkeys(( + "circ_reason", "circ_tripped", "last_completed", "last_start_attempt", + "maint_schema_version", "phase", "phase_started", "schema_version", + "sign_reason", "sign_tripped", "site_sign_reason", "site_sign_tripped", + "unit/inverter-a/last_completed", "unit/inverter-a/last_start_attempt", + "unit/inverter-a/phase", "unit/inverter-a/phase_started", + "unit/inverter-a/sign_reason", "unit/inverter-a/sign_tripped")) +_T23_ARB = dict.fromkeys(( + "circ_reason", "circ_tripped", "last_completed", "last_start_attempt", + "phase", "phase_started", "schema_version", "sign_reason", "sign_tripped", + "site_sign_reason", "site_sign_tripped", "unit/inverter-a/sign_reason", + "unit/inverter-a/sign_tripped")) +check("an old site never started (the real TEST-23 Stores, one in shadow) is " + "not sent to the wizard: its Store came from a file (review R1)", + S(NEW, _T23_ADDON, False) is None and S(NEW, _T23_ARB, False) is None + and S(dict(NEW, shadow_mode="on"), _T23_ARB, False) is None + and S(NEW, {}, False) is None) +check("stored options with no inverter to control always open it, whatever " + "the Store says", + S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True) + == "no_power_request" + and S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": " "}]), + None, True) == "no_power_request") +check("a list roster with a power request entity is set up", + S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": "number.a"}]), + {"auto": True}, True) is None) +check("without a Store the answer is no (never nag on a guess)", + S(NEW, None, False) is None) +_sites = [(n, dict(DEFAULTS, **json.load(open(os.path.join( + HERE, "scenarios", "ux13", "test23", n))))) for n in sorted(os.listdir( + os.path.join(HERE, "scenarios", "ux13", "test23")))] +_starting = [(n, o) for n, o in _sites if not validate_startup_config(o)] +check(f"the TEST-23 sites that start, and run control, are not sent to the " + f"wizard ({[n for n, _o in _starting]})", len(_starting) >= 3 + and all(S(o, {"auto": True, "phase": "idle"}, True) is None + for _n, o in _starting)) +check("...and the one that cannot start (no power request entity) is", + [S(o, {"auto": True}, True) for n, o in _sites + if (n, o) not in _starting] == ["no_power_request"]) + + +async def _first(s, root, sup): + out = {"model": await _get(s, root + "config/model?lang=en")} + m = out["model"] + model = copy.deepcopy(m["ui"]) + model["peak_cap_w"] = 4000 + out["plain"] = await _post(s, root + "config/save", { + "model": model, "fingerprint": m["fingerprint"]}) + out["store_plain"] = dict(_store.data) + out["after_plain"] = await _get(s, root + "config/model?lang=en") + m = out["after_plain"] + model = copy.deepcopy(m["ui"]) + out["wizard"] = await _post(s, root + "config/save", { + "model": model, "fingerprint": m["fingerprint"], "setup": "done"}) + out["after"] = await _get(s, root + "config/model?lang=en") + return out + + +_store = Store({"phase": "idle"}, fresh=True) +_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first)) +check("over HTTP, /config/model says a new install needs setup, with the spec", + _r["model"]["setup_needed"] == "first_start" + and _r["model"]["setup"] == json.loads(json.dumps(SPEC))) +check("a save from the Installer view does not count as setup", + _r["plain"][0] == 200 and _r["after_plain"]["setup_needed"] + == "first_start" and uipage.SETUP_DONE not in _r["store_plain"]) +check("the wizard's save writes the Store marker, and the wizard stops " + "opening by itself", + _r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE) + and _r["after"]["setup_needed"] is None) +_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store(_T23_ARB)), NEW, + _first)) +check("over HTTP, an old site never started (a Store read from a file) is not " + "sent to the wizard (review R1)", _r["model"]["setup_needed"] is None) +with tempfile.TemporaryDirectory() as _d: + from app.store import Store as _RealStore + _path = os.path.join(_d, "state.json") + _new = _RealStore(_path) + _new.set("phase", "idle") + _old = _RealStore(_path) + with open(_path, "w") as _fh: + _fh.write("{not json") + _bad = _RealStore(_path) +check("app/store.py: fresh only when this boot found no state file (a new " + "/data), not after a save and not for an unreadable file", + _new.fresh is True and _old.fresh is False and _bad.fresh is False) +_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}), + auto=True), NEW, _first)) +check("a working site's /config/model says no", _r["model"]["setup_needed"] + is None) +check("the page goes to Setup by itself only when opened with no route and " + "only while still on Home", + "first: !location.hash" in io.open(os.path.join( + HERE, "app", "static", "app.js"), encoding="utf-8").read() + and 'if (M && M.setup_needed && location.hash === "#home") ' + 'location.hash = "#setup";' in JS) + +# --------------------------------------------------------------------------- # +print("\n5. the last step's live checks") +# --------------------------------------------------------------------------- # +ROWS = {s["entity_id"]: {"entity_id": s["entity_id"], "state": s["state"], + "unit": s["attributes"]["unit_of_measurement"]} + for s in STATES} +SITE = dict(NEW, meter_entity="sensor.grid", soc_entity="sensor.inv1_level", + batt_entity="sensor.inv1_battery", pv_entity="sensor.pv") + + +def tiers(notes): + return [(n.tier, n.text) for n in notes] + + +_c = tiers(setup_checks(SITE, entities=ROWS)) +for t in _c: + print(f" {t}") +check("the meter reads, as the add-on will see it", + ("ok", "The meter reads 402 W taken from the grid now.") in _c) +check("with the sign flipped, the same reading is fed in", + ("ok", "The meter reads 402 W fed into the grid now.") in tiers( + setup_checks(dict(SITE, meter_invert=True), entities=ROWS))) +check("the power request entity exists in Home Assistant (a write cannot " + "tell: SAFETY-77)", + ("ok", "Home Assistant has number.inv1_power, the power request " + "entity.") in _c) +check("the battery level reads, and the battery power with its direction", + ("ok", "The battery level reads 62 %.") in _c + and ("info", "The battery reads 800 W charging now, so check the " + "inverter shows the same.") in _c) +check("a solar entity below zero, not flipped, looks reversed", + ("warn", "sensor.pv reads below zero now, so turn on its “Reads " + "negative” switch in the Solar step.") in _c + and ("ok", "sensor.pv reads 1 200 W of solar production now.") + in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]), + entities=ROWS))) +_miss = tiers(setup_checks(dict(SITE, setpoint_entity="number.typo"), + entities=ROWS)) +check("a power request entity Home Assistant does not have is red, so the " + "wizard does not save", + ("bad", "Home Assistant has no entity number.typo, which “Inverter " + "setpoint entity” (setpoint_entity) names.") in _miss) +check("a missing battery level entity is a warning, not red (the add-on " + "holds 0 W and says so)", + [t for t, x in tiers(setup_checks(dict(SITE, soc_entity="sensor.x"), + entities=ROWS)) + if "sensor.x" in x] == ["warn"]) +_two = dict(DEFAULTS, meter_source="ha_signed", p1_net_entity="sensor.net", + inverters=[{"name": "a", "setpoint_entity": "number.inv1_power", + "soc_entity": "sensor.inv1_level", "max_w": 2000, + "bms_soc_floor": 10}, + {"name": "b", "setpoint_entity": "number.b", + "max_w": 2000, "bms_soc_floor": 10}]) +_c2 = tiers(setup_checks(_two, entities=ROWS)) +check("with two inverters each is named, and a kW meter is shown as the " + "add-on reads it, beside the warning to pick a sensor in W", + ("ok", "The meter reads 3 W fed into the grid now.") in _c2 + and [t for t, x in _c2 if "reads in kW" in x] == ["warn"] + and ("bad", "Home Assistant has no entity number.b, which “Inverter " + "setpoint entity” for b (inverters.setpoint_entity) names.") + in _c2 + and ("ok", "The battery level for a reads 62 %.") in _c2 + and ("warn", "There is no battery level entity for b, so pick one in " + "step 2 or the add-on holds 0 W.") in _c2) +_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid", + p1_export_entity="sensor.inv1_level") +check("import and export are read as their difference", + ("ok", "The meter reads 340 W taken from the grid now.") + in tiers(setup_checks(dict(_dsmr), entities=dict( + ROWS, **{"sensor.inv1_level": {"state": "62", "unit": "W"}})))) +_hw = dict(SITE, meter_source="homewizard_local", p1_host="192.0.2.5") +check("a meter the add-on reads itself: its live reading when it already " + "runs on it, else a note that Home shows it after the restart", + ("ok", "The meter reads 250 W taken from the grid now.") in tiers( + setup_checks(_hw, entities=ROWS, running=_hw, meter_now_w=250)) + and [t for t, x in tiers(setup_checks(_hw, entities=ROWS, running=SITE, + meter_now_w=250)) + if "meter" in x] == ["info"]) +check("an unreadable meter is a warning", + ("warn", "sensor.grid reads unavailable now, so check it in Home " + "Assistant before you start.") in tiers(setup_checks(SITE, entities=dict( + ROWS, **{"sensor.grid": {"state": "unavailable", + "unit": "W"}})))) +_down = tiers(setup_checks(SITE, entities=None)) +check("Home Assistant down: red, because the power request entity cannot be " + "checked (review R4), and nothing claimed ok about an entity", + _down[0] == ("bad", "Home Assistant is not answering, so press Check " + "again once it is back.") + and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x]) +# Review R5: meter + solar + battery is the house's use, never below zero. A +# battery giving 1 500 W whose sensor reads it the wrong way (-1 500 W). +_rev = dict(ROWS, **{"sensor.pv": {"state": "0", "unit": "W"}, + "sensor.inv1_battery": {"state": "-1500", "unit": "W"}}) +_bal = ("warn", "The readings add up to -1 098 W of house use, so check each " + "battery's “Battery power sign is reversed” switch in step 2.") +check("a battery sensor reading the wrong way while giving power is flagged " + "(review R5); not once its switch is on, not without solar, not with " + "the battery reading unknown, and not on the normal site", + _bal in tiers(setup_checks(SITE, entities=_rev)) + and not [x for t, x in tiers(setup_checks(dict(SITE, batt_invert=True), + entities=_rev)) + if "add up" in x] + and not [x for t, x in tiers(setup_checks(dict(SITE, pv_entity=""), + entities=_rev)) + if "add up" in x] + and not [x for t, x in tiers(setup_checks(dict(SITE, batt_entity=""), + entities=_rev)) + if "add up" in x] + and not [x for x in (t[1] for t in _c) if "add up" in x]) +_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{ + "ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3})) +_dyn = dict(SITE, price_provider="generic_dayahead") +check("prices: known from a contract; for a feed, today's count when it " + "already runs, else after the restart; none when none is set", + ("ok", "Your prices come from your contract, so they are always known.") + in tiers(setup_checks(_fixed, entities=ROWS)) + and ("ok", "Prices for today are in, for 20 of 24 hours.") + in tiers(setup_checks(_dyn, entities=ROWS, running=_dyn, + prices_known=20)) + and ("info", "Prices are fetched after the restart, and Home shows " + "them.") in tiers(setup_checks(_dyn, entities=ROWS, + running=SITE, + prices_known=20)) + and ("info", "No prices are set, so the batteries do not charge on " + "price.") in _c) + + +async def _final(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + model = copy.deepcopy(m["ui"]) + model["inverters"][0]["setpoint_entity"] = "number.typo" + out = {"ok": (await _post(s, root + "config/validate", + {"model": m["ui"], "setup": True}))[1], + "typo": (await _post(s, root + "config/validate", + {"model": model, "setup": True}))[1], + "plain": (await _post(s, root + "config/validate", + {"model": m["ui"]}))[1]} + return out + + +_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0), + SITE, _final)) +check("over HTTP, the last step gets the checks beside the refusals, read " + "from a fresh entity list", + [x["tier"] for x in _r["ok"]["setup"] if "number.inv1_power" in x["text"]] + == ["ok"] and not [m for m in _r["ok"]["messages"] if m["tier"] == "bad"] + and "setup" not in _r["plain"]) +check("...and a typed power request entity HA does not have is red there", + ["bad"] == [x["tier"] for x in _r["typo"]["setup"] + if "number.typo" in x["text"]]) + + +async def _kettle(s, root, sup): + return (await _get(s, root + "ha/entities?entity=sensor.net"), + await _get(s, root + "ha/entities?entity=sensor.gone")) + + +(_k, _gone), _sup = asyncio.run(session(Controller(SITE, STATES), SITE, + _kettle)) +check("the kettle test reads one entity now, in W, and says when it is not " + "there", _k == {"entity_id": "sensor.net", "found": True, + "state": "-3.1", "unit": "kW", "watts": -3100.0} + and _gone["found"] is False and _gone["watts"] is None) + +# --------------------------------------------------------------------------- # +print("\n6. the save goes through the P2 path and writes a valid blob") +# --------------------------------------------------------------------------- # +_plan = {"meter": "signed", "roster": "two", "solar": "two", + "prices": "daynight", "region": "flanders", "peak": "forecast", + "shadow": "on"} + + +async def _wizard(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + model = B.browser(B.apply(m["ui"], _plan)) + v = (await _post(s, root + "config/validate", {"model": model, + "setup": True}))[1] + saved = await _post(s, root + "config/save", { + "model": model, "fingerprint": m["fingerprint"], "setup": "done"}) + return v, saved + + +_store = Store() +(_v, _saved), _sup = asyncio.run(session(Controller(NEW, STATES, _store), + NEW, _wizard)) +_blob = _sup.writes[-1] if _sup.writes else {} +check("the wizard's model validates clean and saves with 200", + not [m for m in _v["messages"] if m["tier"] == "bad"] + and _saved[0] == 200 and len(_sup.writes) == 1) +check("the blob written starts: the shipped validator accepts it", + not validate_startup_config(_blob)) +check("it has what the six steps set: the net meter, two named inverters, " + "two solar rows with one flipped, day and night prices, the Flanders " + "levies, the peak limit, and shadow mode on", + _blob["meter_source"] == "ha_signed" + and [i["name"] for i in _blob["inverters"]] == ["inverter-1", + "inverter-2"] + and _blob["setpoint_entity"] == "" + and _blob["pv_entities"] == ["sensor.pv_a", "sensor.pv_b"] + and _blob["pv_invert_entities"] == ["sensor.pv_b"] + and json.loads(_blob["static_profile"])["periods"][0]["eur_per_kwh"] + == 0.34 and _blob["price_taxes_eur_kwh"] + == U.REGION_PRESETS["flanders"]["taxes_eur_kwh"] + and _blob["peak_cap_w"] == 4200 and _blob["shadow_mode"] == "on") +check("every key config.yaml declares is written, in the stored order", + list(_blob)[:len(NEW)] == list(NEW) and set(_blob) == set(NEW)) +check("the wizard saved without a restart of its own: the restart is a " + "second, confirmed request", _sup.restarts == 0 + and 'JSON.stringify({ confirm: "restart" })' in JS.split( + "async function finish")[1]) +_all = B.blobs() +_new = [(n, r) for n, _b, r in _all if n.startswith("new-")] +_ok = [n for n, _b, r in _all if not r] +print(f" {len(_all)} blobs from {len(B.starts())} starts; " + f"{len(_ok)} accepted") +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]) +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) +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)) + +# --------------------------------------------------------------------------- # +print("\n7. a stale save names only what changed elsewhere (P2 finding a)") +# --------------------------------------------------------------------------- # + + +async def _stale(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + sup.options["peak_cap_w"] = 5000 # the Configuration tab + 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"]}) + again = await _post(s, root + "config/save", + {"model": model, "fingerprint": first[1]["fingerprint"]}) + return first, again + + +(_first_r, _again), _sup = asyncio.run(session(Controller(SITE, STATES), SITE, + _stale)) +print(f" changed_fields {_first_r[1]['changed_fields']}, moved " + f"{_first_r[1]['moved']}, changed {_first_r[1]['changed']}") +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("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 + and _sup.options["price_timeout_s"] == 12) + + +async def _two_pages(s, root, sup): + """The classic page and this one open the same blob; the classic one + saves first (the browser run found the token was dropped then).""" + m = await _get(s, root + "config/model?lang=en") + classic = await _get(s, root + "config/model?lang=en") + theirs = dict(classic["options"], peak_cap_w=5000) + await _post(s, root + "config/save", {"options": theirs, + "fingerprint": classic["fingerprint"]}) + model = copy.deepcopy(m["ui"]) + model["price_timeout_s"] = 12 + return await _post(s, root + "config/save", + {"model": model, "fingerprint": m["fingerprint"]}) + + +_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _two_pages)) +check("two pages on one blob: after the other page saved, this one's banner " + "still names only the other page's change", + _r[0] == 409 and _r[1]["changed_fields"] == ["peak_cap_w"]) + + +async def _unknown(s, root, sup): + m = await _get(s, root + "config/model?lang=en") + sup.options["peak_cap_w"] = 5000 + model = copy.deepcopy(m["ui"]) + model["price_timeout_s"] = 12 + return await _post(s, root + "config/save", + {"model": model, "fingerprint": "not-handed-out"}) + + +_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _unknown)) +check("a token the add-on did not hand out (it restarted) falls back to " + "every key that differs, as before", + _r[0] == 409 and _r[1]["moved"] is None + and _r[1]["changed_fields"] == ["peak_cap_w", "price_timeout_s"]) + +# --------------------------------------------------------------------------- # +print("\n8. the wizard's words keep to STYLE") +# --------------------------------------------------------------------------- # + + +def _texts(node, path=""): + for k, v in node.items(): + if isinstance(v, dict): + yield from _texts(v, f"{path}{k}.") + elif isinstance(v, str): + yield f"{path}{k}", v + + +def _sentences(t): + return len(re.findall(r"[.!?](?:\s|$)", t)) + + +_long = [(p, t) for p, t in _texts(WS) + if len(t.split()) > (25 if p.endswith("description") else 6) + or (p.endswith("description") and _sentences(t) > 1) or ";" in t] +check(f"every wizard string is one sentence of at most 25 words, or a name " + f"of at most 6 ({_long})", not _long and len(list(_texts(WS))) > 50) +_rendered = [x for notes in ( + setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS), + setup_checks(SITE, entities=None), setup_checks(_hw, entities=ROWS), + setup_checks(_fixed, entities=ROWS), setup_checks(SITE, entities=_rev), + setup_checks(dict( + SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"), + entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}}))) + for x in (n.text for n in notes)] +_over = [t for t in _rendered if len(t.split()) > 25 or _sentences(t) > 1 + or ";" in t] +check(f"every live check, rendered with real values, is one sentence of at " + f"most 25 words ({len(_rendered)} rendered, over: {_over})", + len(_rendered) > 20 and not _over) +_dialogs = [WS["dialog"]["description"], WS["dialog_live"]["description"]] +check("both restart dialogs name the 0 W, and the shadow one says it stays", + all("0 W" in d for d in _dialogs) and "shadow mode keeps them at 0 W" + in _dialogs[0]) +check("the wizard defaults to shadow mode: the switch starts on", + "shadowOn: true" in JS) +check("the shadow switch never writes into V, which the Installer view saves " + "too: it goes only on the model the wizard posts (review R3)", + "V[M.setup.shadow.field]" not in JS and "V[s.field]" not in JS + and JS.count("model: posted()") == 2) +check("a skip after a fall keeps saying the reading went down (review R2)", + "wrong: !!k.wrong }; drawStep();" in JS + and "WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped" in JS + and 'const wrong = o.say === "none" ? !!k.wrong : o.sets == null;' in JS) +_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search( + r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS)) +check(f"settings.js, wizard included, names no option key ({_leak})", + not _leak) + +print() +if fails: + print(f"{len(fails)} FAILED") + sys.exit(1) +print("all passed") diff --git a/goodwe_controller/tools/ux13_demo.py b/goodwe_controller/tools/ux13_demo.py index a00ffca..d430ec9 100644 --- a/goodwe_controller/tools/ux13_demo.py +++ b/goodwe_controller/tools/ux13_demo.py @@ -11,6 +11,12 @@ P2: the fixture Supervisor serves config.yaml's defaults with this site's options, a schema read off config.yaml the way the Supervisor renders it (`schema_ui()`) and translations/en.yaml, and keeps what the settings view saves in memory, so the Installer view can be clicked through end to end. + +P4: STATE `new` is a new install (config.yaml's defaults with one power +request entity, an empty Store made at this boot, control never started), so the page opens +the setup wizard by itself. A one-entity read of sensor.grid steps through +KETTLE_READS, so the kettle test sees the kettle go on (up) and then a +reversed meter (down). """ import asyncio @@ -39,6 +45,8 @@ PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, class Store(dict): + fresh = False # app/store.py: no state file at this boot + def set(self, k, v): self[k] = v @@ -74,8 +82,12 @@ class Hass: async def number(self, _e, invert=False): return None - async def state(self, _e): - return None + async def state(self, e): + s = next((dict(x) for x in STATES if x["entity_id"] == e), None) + if s and e == "sensor.grid": + n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1 + s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)]) + return s async def set_number(self, _e, _v): return True @@ -87,6 +99,10 @@ class Hass: return STATES +KETTLE_READS = (-10, 1990, 1990, -10) +KETTLE_COUNT = [0] + + def _state(eid, name, state, unit=None, cls=None): return {"entity_id": eid, "state": state, "attributes": {"friendly_name": name, "unit_of_measurement": unit, @@ -240,6 +256,18 @@ def controller(state): async def main(state, port, host="127.0.0.1"): + if state == "new": + stored = dict(defaults(CONFIG), setpoint_entity="number.a") + store = Store() + store.fresh = True # a new /data: the wizard opens by itself + c = Controller(dict(stored), Hass(), store, Pub(), + stop=asyncio.Event()) + c.auto = False + runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored))) + await runner.setup() + await aioweb.TCPSite(runner, host, port).start() + print(f"serving new on http://127.0.0.1:{port}/", flush=True) + await asyncio.Event().wait() c = controller(state) stored = dict(defaults(CONFIG), **OPTS) stored.update(setpoint_entity="", meter_source="ha_signed", diff --git a/goodwe_controller/tools/ux13_wizard_blobs.py b/goodwe_controller/tools/ux13_wizard_blobs.py new file mode 100644 index 0000000..8b74ee7 --- /dev/null +++ b/goodwe_controller/tools/ux13_wizard_blobs.py @@ -0,0 +1,254 @@ +"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's +own validator run over them. + + python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs + docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \ + --entrypoint python3 \ + ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \ + /w/tools/ux13_wizard_blobs.py --supervisor /blobs + +The wizard edits the page model one step at a time and saves it through +uipage.to_options(), like the Installer view. `blobs()` walks each step's +choices (meter source and sign, one to three inverters, solar rows, every +contract and price source, region, peak limit, shadow mode) from a new +install and from the TEST-23 sites, and keeps what the shipped validator +accepts, because the wizard saves nothing it refuses. test_setup.py checks +the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions` +offline, as the P2 test did. It touches nothing: no network, no Supervisor. +""" + +import copy +import glob +import json +import os +import sys + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def browser(value): + """What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3.""" + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, list): + return [browser(v) for v in value] + if isinstance(value, dict): + return {k: browser(v) for k, v in value.items()} + return value + + +# --- the wizard's steps, as edits to the page model ------------------------ # +METERS = { + "entity": {"meter_source": "off", "meter_entity": "sensor.grid"}, + "entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid", + "meter_invert": True}, + "signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"}, + "signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net", + "meter_phases": 3, "p1_phase_net_entities": [ + "sensor.l1", "sensor.l2", "sensor.l3"]}, + "dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp", + "p1_export_entity": "sensor.exp"}, + "homewizard": {"meter_source": "homewizard_local", + "p1_host": "192.0.2.50"}, + "mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"}, +} + + +def _card(i, flat=False, **more): + card = {"setpoint_entity": f"number.inv{i}_power", + "soc_entity": f"sensor.inv{i}_level", + "batt_entity": f"sensor.inv{i}_battery", "batt_invert": False, + "max_w": 2500, "bms_soc_floor": 10} + if flat: + card.update(name="", capacity_kwh=None) + else: + card.update(name=f"inverter-{i}", capacity_kwh=5.0) + card.update(more) + return card + + +ROSTERS = { + "one": ("flat", [_card(1, flat=True)]), + "one_flipped": ("flat", [_card(1, flat=True, batt_invert=True, + max_w=3000)]), + "two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]), + "three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]), +} +SOLAR = { + "none": [], + "one": [{"entity": "sensor.pv", "reversed": False}], + "one_reversed": [{"entity": "sensor.pv", "reversed": True}], + "two": [{"entity": "sensor.pv_a", "reversed": False}, + {"entity": "sensor.pv_b", "reversed": True}], +} +PRICES = { + "none": {"ui.contract": "none", "ui.fallback": "none"}, + "fixed": {"ui.contract": "fixed", "ui.fallback": "fixed", + "ui.fixed_price": 0.31}, + "daynight": {"ui.contract": "daynight", "ui.fallback": "daynight", + "ui.day_price": 0.34, "ui.night_price": 0.25}, + "dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead", + "ui.fallback": "none"}, + "dynamic_fixed": {"ui.contract": "dynamic", + "price_provider": "generic_dayahead", + "ui.fallback": "fixed", "ui.fixed_price": 0.3}, + "dynamic_daynight": {"ui.contract": "dynamic", + "price_provider": "generic_dayahead", + "ui.fallback": "daynight", "ui.day_price": 0.3, + "ui.night_price": 0.2}, + "formula": {"ui.contract": "dynamic", "price_provider": "custom_formula", + "price_formula": "", + "ui.fallback": "fixed", + "ui.fixed_price": 0.3}, + "csv": {"ui.contract": "dynamic", "price_provider": "manual_csv", + "price_csv_path": "/share/prices.csv", "ui.fallback": "fixed", + "ui.fixed_price": 0.3}, +} +REGIONS = {"flanders": {"ui.region": "flanders"}, + "own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}} +PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""}, + "forecast": {"peak_cap_w": 4200, + "peak_forecast_entity": "sensor.peak_forecast"}, + "high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}} +SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW) +BASE = {"meter": "entity", "roster": "one", "solar": "none", + "prices": "none", "region": "own", "peak": "low", "shadow": "on"} + + +def plans(): + """Each step's choices once, with the other steps at BASE, then a few + whole sites that combine them.""" + axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR, + "prices": PRICES, "region": REGIONS, "peak": PEAKS, + "shadow": dict.fromkeys(SHADOWS)} + seen = [] + for axis, choices in axes.items(): + for choice in choices: + plan = dict(BASE, **{axis: choice}) + if plan not in seen: + seen.append(plan) + for plan in ( + {"meter": "dsmr", "roster": "two", "solar": "two", + "prices": "dynamic_daynight", "region": "flanders", + "peak": "forecast", "shadow": "on"}, + {"meter": "signed_3", "roster": "three", "solar": "one_reversed", + "prices": "daynight", "region": "flanders", "peak": "high", + "shadow": "auto"}, + {"meter": "homewizard", "roster": "one_flipped", "solar": "one", + "prices": "fixed", "region": "own", "peak": "forecast", + "shadow": "on"}, + {"meter": "mqtt", "roster": "two", "solar": "none", + "prices": "formula", "region": "flanders", "peak": "low", + "shadow": "on"}): + seen.append(plan) + return seen + + +def apply(model, plan): + """The page model after the wizard's six steps, as the page leaves it.""" + from app import uilayout as L, uimodel as U + m = copy.deepcopy(model) + m.update(copy.deepcopy(METERS[plan["meter"]])) + shape, cards = ROSTERS[plan["roster"]] + cards = copy.deepcopy(cards) + if m.get("inverter_shape") == "list": + # A site stored as a list stays one: one card keeps a name. + for i, card in enumerate(cards, 1): + card.update(name=card["name"] or f"inverter-{i}", + capacity_kwh=card["capacity_kwh"] or 5.0) + m["inverters"] = cards + m[U.COUNT] = len(cards) + if shape == "list": + m.update(U.GROWN) + m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]]) + m.update(copy.deepcopy(PRICES[plan["prices"]])) + if plan["prices"] == "formula": + from app.custom_formula import _LEGS + m["price_formula"] = _LEGS["price_formula"].example + m.update(copy.deepcopy(REGIONS[plan["region"]])) + if plan["region"] != "own": + m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][ + "taxes_eur_kwh"] + m.update(PEAKS[plan["peak"]]) + field, on, off = L.SHADOW + m[field] = on if plan["shadow"] == "on" else ( + m.get(field) if m.get(field) != on else off) + return m + + +def starts(): + """(name, stored options) the wizard opens on: a new install (config.yaml's + defaults and one power request entity, typed in the Configuration tab so + the add-on starts) and the TEST-23 sites.""" + sys.path.insert(0, os.path.join(HERE, "tools")) + import ux13_demo as D + new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power") + out = [("new", new)] + for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23", + "*.json")))[:3]: + out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG), + **json.load(open(f))))) + return out + + +def blobs(): + """[(name, blob, refusals)] for every plan from every start.""" + sys.path.insert(0, HERE) + from app import uimodel as U, uipage + from app.validate import validate_startup_config + out = [] + for start, stored in starts(): + model = json.loads(json.dumps(U.options_to_ui(stored))) + for plan in plans(): + name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items()) + posted = browser(json.loads(json.dumps(apply(model, plan)))) + blob = uipage.to_options(posted, stored) + out.append((name, blob, validate_startup_config(blob))) + return out + + +# --- run inside the Supervisor image --------------------------------------- # +def supervisor(folder): + sys.path.insert(0, "/usr/src/supervisor") + import voluptuous as vol + from pathlib import Path + from supervisor.apps.options import AppOptions + from supervisor.apps.validate import SCHEMA_APP_CONFIG + from supervisor.utils.yaml import read_yaml_file + cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml")) + check = vol.Schema(vol.All(dict, AppOptions( + None, cfg["schema"], cfg["name"], "local_goodwe_controller"))) + bad = 0 + files = sorted(glob.glob(os.path.join(folder, "*.json"))) + for f in files: + blob = json.load(open(f)) + try: + got = check(copy.deepcopy(blob)) + dropped = sorted(set(blob) - set(got)) + ok = not dropped + why = f"dropped {dropped}" if dropped else f"{len(got)} keys" + except vol.Invalid as err: + ok, why = False, str(err) + bad += not ok + print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}") + print(f"{len(files) - bad} of {len(files)} accepted by AppOptions " + f"({cfg['version']})") + return 1 if bad or not files else 0 + + +if __name__ == "__main__": + if sys.argv[1:2] == ["--supervisor"]: + sys.exit(supervisor(sys.argv[2])) + if sys.argv[1:2] == ["--dump"]: + os.makedirs(sys.argv[2], exist_ok=True) + n = 0 + for name, blob, refused in blobs(): + if refused: + print("refused, not written:", name, refused[0]) + continue + with open(os.path.join(sys.argv[2], name + ".json"), "w") as f: + json.dump(blob, f, indent=1) + n += 1 + print(f"{n} blobs written to {sys.argv[2]}") + sys.exit(0) + print(__doc__)