From be57b87c34a3723d6495415a590b88165fc8e86f Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 28 Sep 2026 05:31:18 +0200 Subject: [PATCH 1/2] UX-13 P4: the setup wizard for installers The Setup tab draws the Installer view's page model one step at a time, from each field's `wizard` step in app/uilayout.py: meter (with the kettle test), inverters and batteries, solar (optional), prices, peak limit (battery modes: "comes in a later update", OPT-11), check and start. It reuses P2's controls and saves through /config/validate and /config/save. No route was added. - First run: /config/model says `setup_needed` when the stored options name no power request entity, or on a new install (no `setup_done` and no `auto` in the Store, control not started). The page opens #setup only when opened with no route. The wizard's save writes `setup_done`. - Kettle test: GET ha/entities?entity= reads one entity now, in W. The change decides: up keeps meter_invert off, down turns it on; a net or import/export meter that goes down blocks the step with what to check. - Last step: validate.setup_checks() beside the refusals: the meter reads, each power request entity exists in HA (bad if not, SAFETY-77), battery levels and power with direction, solar sign, prices. Saves with shadow_mode on by default, then a confirmed restart naming the 0 W. - P2 test finding (a): a stale save lists only the keys the other surface moved (the blob handed out on open, by fingerprint, against stored now). - test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard can save; all accepted by Supervisor 2026.08.0 AppOptions (offline). - DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p --- .../DECISIONS/UX-13-one-layout-table.md | 4 + .../DECISIONS/UX-13-setup-wizard.md | 93 +++ goodwe_controller/DOCS.md | 26 + goodwe_controller/app/haentities.py | 30 +- goodwe_controller/app/selfconfig.py | 23 +- goodwe_controller/app/static/app.css | 8 + goodwe_controller/app/static/app.js | 4 +- goodwe_controller/app/static/index.html | 15 +- goodwe_controller/app/static/settings.js | 282 ++++++- goodwe_controller/app/ui_text/en.json | 58 +- goodwe_controller/app/uilayout.py | 28 + goodwe_controller/app/uipage.py | 77 ++ goodwe_controller/app/validate.py | 240 +++++- goodwe_controller/app/web.py | 72 +- goodwe_controller/test_messages.py | 2 +- goodwe_controller/test_settings.py | 6 +- goodwe_controller/test_setup.py | 694 ++++++++++++++++++ goodwe_controller/tools/ux13_demo.py | 28 +- goodwe_controller/tools/ux13_wizard_blobs.py | 254 +++++++ 19 files changed, 1885 insertions(+), 59 deletions(-) create mode 100644 goodwe_controller/DECISIONS/UX-13-setup-wizard.md create mode 100644 goodwe_controller/test_setup.py create mode 100644 goodwe_controller/tools/ux13_wizard_blobs.py 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..d3fa8a6 --- /dev/null +++ b/goodwe_controller/DECISIONS/UX-13-setup-wizard.md @@ -0,0 +1,93 @@ +# 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`**: the Store has no `setup_done` and no `auto` key, and + control is not started. `auto` is the key Start/Stop writes, and + config.yaml ships `auto_start` off (the same "fresh install" reading as + app/shadow.py's `auto` default), so this is a site that has never run or + been stopped by hand and never finished the wizard. + +A site that runs control, or ever started or stopped it, is never sent to the +wizard. 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; the last step then warns. + +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. + +## 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. 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`. + +## 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..3d6c8e2 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 when the page is opened on a new install: control was +never started or stopped and Setup never finished, or the stored settings name +no inverter to control. A running site 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..75d5c00 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,234 @@ 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 }; + else { + const o = kettleOutcome(src, k.before, w); + WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null }; + 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 }; drawStep(); + } }, WS.kettle.skip)); + box.append(row); + return box; + } + + // Step 6: the shadow switch, the checks, and Save and restart. + function applyShadow() { + const s = M.setup.shadow; + V[s.field] = WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off); + } + 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; applyShadow(); queueValidate(); 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 }); + 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", 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 = V[M.setup.shadow.field] === M.setup.shadow.on; + ask(shadow ? WS.dialog : WS.dialog_live, async () => { + wzBanner(); + let res; + try { res = await postSave({ setup: "done" }); } 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)); + if (n === S.final) applyShadow(); + 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/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index dbb87a0..f4f594a 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,59 @@ "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."}, + "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..ce2b269 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) -> 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": this /data has never been + through the wizard and control was never started or stopped, which is a + new install: config.yaml ships `auto_start` off, so it comes up stopped. + A site that runs, or ever ran, control is never sent to the wizard. + `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: + 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..d1ad560 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,230 @@ 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 +# 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: + _advise(notes, "warn", (), "Home Assistant is not answering, so " + "no entity was checked yet.") + else: + _entity_notes(notes, opts, entities, bad=("setpoint_entity",)) + _meter_check(notes, opts, entities, running, meter_now_w) + for who, entry in _cards(opts): + _card_checks(notes, who, entry, entities) + 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: + continue + if w < -IDLE_W and entity not in flipped: + _advise(notes, "warn", ("pv_invert_entities",), + f"{entity} reads below zero now, so its solar " + "production sign looks reversed.") + else: + w = -w if entity in flipped else w + _advise(notes, "ok", ("pv_entity",), + f"{entity} reads {_signed(w)} of solar production " + "now.") + _price_check(notes, opts, running, prices_known) + return notes + + +def _meter_check(notes, opts, entities, running, meter_now_w) -> None: + 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)) + else: + _advise(notes, "info", ("meter_source",), "The add-on reads " + "this meter itself, so Home shows its reading after " + "the restart.") + return + if entities is None: + return + total = 0.0 + for entity, sign in parts: + entity = str(entity or "").strip() + if not entity or entity not in entities: + return # 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 the meter has no " + "reading yet.") + return + total += sign * w + if source == "off" and opts.get("meter_invert"): + total = -total + _meter_line(notes, 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) -> None: + if entities is None: + return + 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 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 + 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.") + + +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..f294a99 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,10 @@ def build_app(controller, selfcfg=None) -> web.Application: "layout": uipage.layout(info.get("schema") or [], info.get("translations") or {}, lang), "ui": options_to_ui(options), + # UX-13 P4: the setup wizard, and why it opens by itself. + "setup": uipage.setup_spec(), + "setup_needed": uipage.setup_needed( + options, store_data(), bool(getattr(controller, "auto", False))), }) return web.json_response(model) @@ -213,8 +237,27 @@ def build_app(controller, selfcfg=None) -> web.Application: if model is not None: payload["messages"] = uipage.anchors(problems, notes, model, _keys_of(options)) + payload["steps"] = uipage.step_problems(payload["messages"], model) + if _body.get("setup"): + payload["setup"] = [n._asdict() for n in await _setup_checks(options)] return web.json_response(payload) + async def _setup_checks(options): + """The wizard's live checks: a list at most a few seconds old, and + what the running add-on already knows (its meter, today's prices).""" + rows, age = await entities.get(max_age=5.0) + if age is not None and age >= 30.0: + rows = None # Home Assistant is not answering + try: + prices = (controller.status_payload().get("plan") or {}).get( + "prices") or [] + known = sum(1 for p in prices if p.get("eur_kwh") is not None) + except Exception: # noqa: BLE001 - a display figure, never a failure + known = None + return setup_checks(options, entities=rows, running=controller.o, + meter_now_w=getattr(controller, "grid", None), + prices_known=known) + def _keys_of(options): # ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is # sound only because the page posts the WHOLE blob it read - which is @@ -249,21 +292,26 @@ def build_app(controller, selfcfg=None) -> web.Application: expect = str(body.get("fingerprint") or "") try: result = await selfconfig.save(selfcfg, options, expect, - hass=controller.hass) + hass=controller.hass, + base=handed.get(expect)) except selfconfig.SaveRefused as refused: if refused.kind == "stale": stored = refused.stored or {} + # The fields to look at are the ones the OTHER surface moved, + # not this page's own edits (UX-13 P2 test, finding a). + moved = (refused.changed if refused.moved is None + else refused.moved) return web.json_response( {"kind": "stale", "changed": refused.changed, - "changed_fields": (uipage.fields_of(refused.changed, - model) + "moved": refused.moved, + "changed_fields": (uipage.fields_of(moved, model) if model is not None else []), "stored": stored, # The page needs the CURRENT token to be able to save # again after the operator has read the difference. That # is not a force door: it takes a second, deliberate press # made in front of what changed. - "fingerprint": selfconfig.fingerprint(stored)}, + "fingerprint": hand_out(stored)}, status=409) by_key, unanchored = problems_by_field(refused.problems, _keys_of(options)) @@ -272,6 +320,8 @@ def build_app(controller, selfcfg=None) -> web.Application: if model is not None: payload["messages"] = uipage.anchors(refused.problems, [], model, _keys_of(options)) + payload["steps"] = uipage.step_problems(payload["messages"], + model) return web.json_response(payload, status=422) except selfconfig.SupervisorUnavailable as err: _LOG.error("saving options failed: %s", err) @@ -279,6 +329,12 @@ def build_app(controller, selfcfg=None) -> web.Application: controller.log_event("configuration saved from the panel - " "not applied until the add-on restarts", hint=messages.hint("settings_saved")) + # `handed` keeps `expect`: another page may have opened the same blob. + store = getattr(controller, "store", None) + if (model is not None and body.get("setup") == "done" + and store is not None): + store.set(uipage.SETUP_DONE, + datetime.now(timezone.utc).isoformat(timespec="seconds")) return web.json_response({"ok": True, **result}) async def config_restart(request): @@ -310,6 +366,10 @@ def build_app(controller, selfcfg=None) -> web.Application: """UX-13: Home Assistant's entities for the picker. A read only, and never an error: when Home Assistant is down the list says so.""" kind = str(request.query.get("kind", "") or "")[:16] + one = str(request.query.get("entity", "") or "")[:255] + if one: + # UX-13 P4's kettle test: one entity, read now. + return web.json_response(await entities.reading(one)) return web.json_response(await entities.payload(kind)) app = web.Application() 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..9c08004 --- /dev/null +++ b/goodwe_controller/test_setup.py @@ -0,0 +1,694 @@ +"""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 + +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): + self.data = dict(data or {}) + + 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: never set up, control never started or stopped", + S(NEW, {}, False) == "first_start" + and S(NEW, {"phase": "idle", "sign_tripped": False}, False) + == "first_start") +check("a site that runs control, or ever ran or stopped it, is not sent to " + "the wizard", S(NEW, {}, True) is None + and S(NEW, {"auto": True}, False) is None + and S(NEW, {"auto": False}, False) 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) 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"}) +_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({"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 its solar production " + "sign looks reversed.") 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 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 the meter has no " + "reading yet.") 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: one warning, nothing claimed ok about an entity", + _down[0] == ("warn", "Home Assistant is not answering, so no entity " + "was checked yet.") + and not [x for t, x in _down if t in ("ok", "bad") and "entity" 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(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) +_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..6a2d3ed 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, 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 @@ -74,8 +80,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 +97,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 +254,16 @@ def controller(state): async def main(state, port, host="127.0.0.1"): + if state == "new": + stored = dict(defaults(CONFIG), setpoint_entity="number.a") + 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__) From 3198f346fdbf402f5038a7d07ae87f0b2aa2246f Mon Sep 17 00:00:00 2001 From: review-agent Date: Mon, 28 Sep 2026 06:12:09 +0200 Subject: [PATCH 2/2] UX-13 P4 review fixes (PR #146, comment 1887) R1: first_start also needs a Store created at this boot (Store.fresh), so old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home. R2: a kettle skip after a fall says on the last step that the reading went down. R3: the shadow switch goes only on the model the wizard posts, never into V. R4: Home Assistant not answering is red on the last step. R5: meter + solar + battery below -300 W flags a reversed battery sensor. R6: the live checks say what to change and where. Co-Authored-By: Claude Opus 5.5 --- .../DECISIONS/UX-13-setup-wizard.md | 30 +++-- goodwe_controller/DOCS.md | 6 +- goodwe_controller/app/static/settings.js | 27 +++-- goodwe_controller/app/store.py | 4 + goodwe_controller/app/ui_text/en.json | 1 + goodwe_controller/app/uipage.py | 12 +- goodwe_controller/app/validate.py | 70 ++++++++---- goodwe_controller/app/web.py | 4 +- goodwe_controller/test_setup.py | 108 +++++++++++++++--- goodwe_controller/tools/ux13_demo.py | 8 +- 10 files changed, 195 insertions(+), 75 deletions(-) diff --git a/goodwe_controller/DECISIONS/UX-13-setup-wizard.md b/goodwe_controller/DECISIONS/UX-13-setup-wizard.md index d3fa8a6..5d7387e 100644 --- a/goodwe_controller/DECISIONS/UX-13-setup-wizard.md +++ b/goodwe_controller/DECISIONS/UX-13-setup-wizard.md @@ -23,14 +23,17 @@ 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`**: the Store has no `setup_done` and no `auto` key, and - control is not started. `auto` is the key Start/Stop writes, and - config.yaml ships `auto_start` off (the same "fresh install" reading as - app/shadow.py's `auto` default), so this is a site that has never run or - been stopped by hand and never finished the wizard. +- **`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. -A site that runs control, or ever started or stopped it, is never sent to the -wizard. Without a Store the answer is no. The wizard's save writes +"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. @@ -52,13 +55,16 @@ 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; the last step then warns. +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. +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 @@ -67,12 +73,14 @@ 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. The wizard does not save while anything is +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`. +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) diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 3d6c8e2..d5367cc 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -3239,9 +3239,9 @@ the last step. 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 when the page is opened on a new install: control was -never started or stopped and Setup never finished, or the stored settings name -no inverter to control. A running site is never sent there. The add-on does +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. diff --git a/goodwe_controller/app/static/settings.js b/goodwe_controller/app/static/settings.js index 75d5c00..603dfcb 100644 --- a/goodwe_controller/app/static/settings.js +++ b/goodwe_controller/app/static/settings.js @@ -636,31 +636,37 @@ 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 }; + 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); - WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null }; + // `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 }; drawStep(); + 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. - function applyShadow() { + // 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; - V[s.field] = WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off); + 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; applyShadow(); queueValidate(); drawStep(); }); + 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)); @@ -669,7 +675,7 @@ const list = $("#wz-checks"); if (list) list.replaceChildren(line("info", WS.check.running.description)); clearTimeout(vtimer); - const b = await validate({ setup: true }); + 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; } @@ -682,7 +688,7 @@ 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", WS.kettle.skipped.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]]); @@ -708,11 +714,11 @@ const b = await runChecks(); if (!b) { toast(WS.check.failed.description); return; } if (WZ.blocked) { toast(WS.check.blocked.description); return; } - const shadow = V[M.setup.shadow.field] === M.setup.shadow.on; + const shadow = shadowValue() === M.setup.shadow.on; ask(shadow ? WS.dialog : WS.dialog_live, async () => { wzBanner(); let res; - try { res = await postSave({ setup: "done" }); } catch (e) { toast(W.toast.failed); return; } + 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) { @@ -758,7 +764,6 @@ ROWS.length = 0; const body = []; if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description)); - if (n === S.final) applyShadow(); 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)); 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 f4f594a..bd5cb3c 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -191,6 +191,7 @@ "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."} }, diff --git a/goodwe_controller/app/uipage.py b/goodwe_controller/app/uipage.py index ce2b269..42dfd52 100644 --- a/goodwe_controller/app/uipage.py +++ b/goodwe_controller/app/uipage.py @@ -291,19 +291,19 @@ SETUP_DONE = "setup_done" STARTED = "auto" -def setup_needed(stored: dict, store_data, started) -> str | None: +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": this /data has never been - through the wizard and control was never started or stopped, which is a - new install: config.yaml ships `auto_start` off, so it comes up stopped. - A site that runs, or ever ran, control is never sent to the wizard. + 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: + 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 diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index d1ad560..2a27f6d 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -1782,6 +1782,9 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None: # --------------------------------------------------------------------------- # # 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") @@ -1848,33 +1851,50 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None, """ notes: list[Note] = [] if entities is None: - _advise(notes, "warn", (), "Home Assistant is not answering, so " - "no entity was checked yet.") + # 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_check(notes, opts, entities, running, meter_now_w) - for who, entry in _cards(opts): - _card_checks(notes, who, entry, entities) + 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 its solar " - "production sign looks reversed.") + 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) -> None: +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)] @@ -1888,28 +1908,29 @@ def _meter_check(notes, opts, entities, running, meter_now_w) -> None: 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)) - else: - _advise(notes, "info", ("meter_source",), "The add-on reads " - "this meter itself, so Home shows its reading after " - "the restart.") - return + 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 + return None total = 0.0 for entity, sign in parts: entity = str(entity or "").strip() if not entity or entity not in entities: - return # a refusal or a missing-entity note says so + 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 the meter has no " - "reading yet.") - return + 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: @@ -1922,9 +1943,11 @@ def _meter_line(notes, w) -> None: _advise(notes, "ok", ("meter_source",), text) -def _card_checks(notes, who, entry, entities) -> None: +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 + return None setpoint = str(entry.get("setpoint_entity") or "").strip() if setpoint and setpoint in entities: _advise(notes, "ok", ("setpoint_entity",), @@ -1933,7 +1956,8 @@ def _card_checks(notes, who, entry, entities) -> None: 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 the add-on holds 0 W.") + 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: @@ -1948,7 +1972,7 @@ def _card_checks(notes, who, entry, entities) -> None: if w is None: _advise(notes, "warn", ("batt_entity",), f"The battery " f"power{who} reads nothing now, so check {batt}.") - return + 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 " @@ -1958,6 +1982,8 @@ def _card_checks(notes, who, entry, entities) -> None: _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: diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index f294a99..730073a 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -199,7 +199,9 @@ def build_app(controller, selfcfg=None) -> web.Application: # 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))), + options, store_data(), bool(getattr(controller, "auto", False)), + fresh=getattr(getattr(controller, "store", None), "fresh", + False) is True), }) return web.json_response(model) diff --git a/goodwe_controller/test_setup.py b/goodwe_controller/test_setup.py index 9c08004..be86f82 100644 --- a/goodwe_controller/test_setup.py +++ b/goodwe_controller/test_setup.py @@ -21,6 +21,7 @@ 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")) @@ -170,8 +171,9 @@ check("a refusal blocks the step of its field, a hidden or non-wizard field's " class Store: """app/store.py's shape: a dict under .data, and set().""" - def __init__(self, data=None): + def __init__(self, data=None, fresh=False): self.data = dict(data or {}) + self.fresh = fresh def set(self, key, value): self.data[key] = value @@ -294,16 +296,38 @@ check("the page moves on only when the step's count is 0, the kettle test is " print("\n4. first run: a new install opens the wizard, a working site not") # --------------------------------------------------------------------------- # S = uipage.setup_needed -check("a new install: never set up, control never started or stopped", - S(NEW, {}, False) == "first_start" - and S(NEW, {"phase": "idle", "sign_tripped": False}, False) +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) is None - and S(NEW, {"auto": True}, False) is None - and S(NEW, {"auto": False}, False) is None) + "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) is None) + 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) @@ -345,7 +369,7 @@ async def _first(s, root, sup): return out -_store = Store({"phase": "idle"}) +_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" @@ -357,6 +381,22 @@ 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"] @@ -399,8 +439,8 @@ check("the battery level reads, and the battery power with its direction", 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 its solar production " - "sign looks reversed.") in _c + ("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))) @@ -430,8 +470,8 @@ check("with two inverters each is named, and a kW meter is shown as the " "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 the add-on " - "holds 0 W.") 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", @@ -447,15 +487,36 @@ check("a meter the add-on reads itself: its live reading when it already " meter_now_w=250)) if "meter" in x] == ["info"]) check("an unreadable meter is a warning", - ("warn", "sensor.grid reads unavailable now, so the meter has no " - "reading yet.") in tiers(setup_checks(SITE, entities=dict( + ("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: one warning, nothing claimed ok about an entity", - _down[0] == ("warn", "Home Assistant is not answering, so no entity " - "was checked yet.") +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") @@ -667,7 +728,8 @@ check(f"every wizard string is one sentence of at most 25 words, or a name " _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(dict( + 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)] @@ -682,6 +744,14 @@ check("both restart dialogs name the 0 W, and the shadow one says it stays", 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})", diff --git a/goodwe_controller/tools/ux13_demo.py b/goodwe_controller/tools/ux13_demo.py index 6a2d3ed..d430ec9 100644 --- a/goodwe_controller/tools/ux13_demo.py +++ b/goodwe_controller/tools/ux13_demo.py @@ -13,7 +13,7 @@ options, a schema read off config.yaml the way the Supervisor renders it 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, control never started), so the page opens +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). @@ -45,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 @@ -256,7 +258,9 @@ def controller(state): async def main(state, port, host="127.0.0.1"): if state == "new": stored = dict(defaults(CONFIG), setpoint_entity="number.a") - c = Controller(dict(stored), Hass(), Store(), Pub(), + 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)))