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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user