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)))