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:
|
`/config/model` gives a reason:
|
||||||
|
|
||||||
- **`no_power_request`**: the stored options name no inverter to control.
|
- **`no_power_request`**: the stored options name no inverter to control.
|
||||||
- **`first_start`**: the Store has no `setup_done` and no `auto` key, and
|
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||||
control is not started. `auto` is the key Start/Stop writes, and
|
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||||
config.yaml ships `auto_start` off (the same "fresh install" reading as
|
`auto` is the key Start/Stop writes. The startup gate exits 78 before the
|
||||||
app/shadow.py's `auto` default), so this is a site that has never run or
|
controller writes the Store, so a new install's first good boot is fresh.
|
||||||
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
|
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||||
wizard. Without a Store the answer is no. The wizard's save writes
|
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
|
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||||
not.
|
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
|
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
|
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
|
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
|
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
|
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")
|
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"
|
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
|
## 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
|
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
|
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)
|
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
|
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.
|
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
|
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
|
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)
|
## 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
|
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.
|
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
|
Setup opens by itself on a new install's first start, until Setup finishes or
|
||||||
never started or stopped and Setup never finished, or the stored settings name
|
control is started, and whenever the stored settings name no inverter to
|
||||||
no inverter to control. A running site is never sent there. The add-on does
|
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
|
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.
|
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; }
|
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
|
||||||
const w = await readMeter(src);
|
const w = await readMeter(src);
|
||||||
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
|
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 {
|
else {
|
||||||
const o = kettleOutcome(src, k.before, w);
|
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(); }
|
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||||||
}
|
}
|
||||||
drawStep();
|
drawStep();
|
||||||
});
|
});
|
||||||
const row = h("div", { class: "row" }, read);
|
const row = h("div", { class: "row" }, read);
|
||||||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
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));
|
} }, WS.kettle.skip));
|
||||||
box.append(row);
|
box.append(row);
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: the shadow switch, the checks, and Save and restart.
|
// 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;
|
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() {
|
function shadowRow() {
|
||||||
const lid = "wzs";
|
const lid = "wzs";
|
||||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
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" },
|
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", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
|
||||||
h("div", { class: "ctl" }, b));
|
h("div", { class: "ctl" }, b));
|
||||||
@@ -669,7 +675,7 @@
|
|||||||
const list = $("#wz-checks");
|
const list = $("#wz-checks");
|
||||||
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
||||||
clearTimeout(vtimer);
|
clearTimeout(vtimer);
|
||||||
const b = await validate({ setup: true });
|
const b = await validate({ setup: true, model: posted() });
|
||||||
WZ.checked = b;
|
WZ.checked = b;
|
||||||
if (!list || !list.isConnected) return b;
|
if (!list || !list.isConnected) return b;
|
||||||
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); 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);
|
if (b.device_note) add("info", b.device_note);
|
||||||
const src = kettleSource();
|
const src = kettleSource();
|
||||||
if (!src) add("info", WS.kettle.self.description);
|
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);
|
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||||||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||||||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||||||
@@ -708,11 +714,11 @@
|
|||||||
const b = await runChecks();
|
const b = await runChecks();
|
||||||
if (!b) { toast(WS.check.failed.description); return; }
|
if (!b) { toast(WS.check.failed.description); return; }
|
||||||
if (WZ.blocked) { toast(WS.check.blocked.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 () => {
|
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
|
||||||
wzBanner();
|
wzBanner();
|
||||||
let res;
|
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;
|
const { r, b: body } = res;
|
||||||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||||||
if (r.status === 422) {
|
if (r.status === 422) {
|
||||||
@@ -758,7 +764,6 @@
|
|||||||
ROWS.length = 0;
|
ROWS.length = 0;
|
||||||
const body = [];
|
const body = [];
|
||||||
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
|
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"));
|
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 === 1) body.push(kettleBox());
|
||||||
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
|
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"):
|
def __init__(self, path: str = "/data/state.json"):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.data = dict(DEFAULTS)
|
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()
|
self.load()
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
@@ -598,6 +601,7 @@ class Store:
|
|||||||
self.data.get("phase"), self.data.get("last_completed"),
|
self.data.get("phase"), self.data.get("last_completed"),
|
||||||
self.data.get("sign_tripped"))
|
self.data.get("sign_tripped"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
self.fresh = True
|
||||||
_LOG.info("no saved state, starting fresh")
|
_LOG.info("no saved state, starting fresh")
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
# A corrupt state file must not stop the controller: losing the
|
# 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."},
|
"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."},
|
"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": {"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."},
|
"tested": {"description": "The meter direction was tested with a kettle."},
|
||||||
"needed": {"description": "Do the kettle test first, or skip it."}
|
"needed": {"description": "Do the kettle test first, or skip it."}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -291,19 +291,19 @@ SETUP_DONE = "setup_done"
|
|||||||
STARTED = "auto"
|
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.
|
"""Why the page opens the wizard by itself, or None.
|
||||||
|
|
||||||
"no_power_request": the stored options name no inverter to control, so
|
"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
|
the add-on cannot start on them. "first_start": a new install. This boot
|
||||||
through the wizard and control was never started or stopped, which is a
|
found no Store file (`fresh`), the wizard never saved and control was never
|
||||||
new install: config.yaml ships `auto_start` off, so it comes up stopped.
|
started or stopped. `fresh` is what tells a new install from an old site
|
||||||
A site that runs, or ever ran, control is never sent to the wizard.
|
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
|
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
||||||
whether control is started now."""
|
whether control is started now."""
|
||||||
if not power_request_entities(stored or {}):
|
if not power_request_entities(stored or {}):
|
||||||
return "no_power_request"
|
return "no_power_request"
|
||||||
if store_data is None:
|
if store_data is None or not fresh:
|
||||||
return None
|
return None
|
||||||
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
||||||
return None
|
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.
|
# Below this a battery or solar reading counts as idle for a sign check.
|
||||||
IDLE_W = 50.0
|
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,
|
# 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.
|
# MQTT) is only known live when the running options read the same one.
|
||||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
_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] = []
|
notes: list[Note] = []
|
||||||
if entities is None:
|
if entities is None:
|
||||||
_advise(notes, "warn", (), "Home Assistant is not answering, so "
|
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||||
"no entity was checked yet.")
|
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||||
|
"answering, so press Check again once it is back.")
|
||||||
else:
|
else:
|
||||||
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
||||||
_meter_check(notes, opts, entities, running, meter_now_w)
|
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
||||||
for who, entry in _cards(opts):
|
batts = [_card_checks(notes, who, entry, entities)
|
||||||
_card_checks(notes, who, entry, entities)
|
for who, entry in _cards(opts)]
|
||||||
|
solar = []
|
||||||
if entities is not None:
|
if entities is not None:
|
||||||
flipped = pv.inverted(opts)
|
flipped = pv.inverted(opts)
|
||||||
for entity in pv.entities(opts):
|
for entity in pv.entities(opts):
|
||||||
w, _unit = _reading(entities, entity, convert=True)
|
w, _unit = _reading(entities, entity, convert=True)
|
||||||
if entity not in entities or w is None:
|
if entity not in entities or w is None:
|
||||||
|
solar.append(None)
|
||||||
continue
|
continue
|
||||||
if w < -IDLE_W and entity not in flipped:
|
if w < -IDLE_W and entity not in flipped:
|
||||||
|
solar.append(None)
|
||||||
_advise(notes, "warn", ("pv_invert_entities",),
|
_advise(notes, "warn", ("pv_invert_entities",),
|
||||||
f"{entity} reads below zero now, so its solar "
|
f"{entity} reads below zero now, so turn on its "
|
||||||
"production sign looks reversed.")
|
"“Reads negative” switch in the Solar step.")
|
||||||
else:
|
else:
|
||||||
w = -w if entity in flipped else w
|
w = -w if entity in flipped else w
|
||||||
|
solar.append(w)
|
||||||
_advise(notes, "ok", ("pv_entity",),
|
_advise(notes, "ok", ("pv_entity",),
|
||||||
f"{entity} reads {_signed(w)} of solar production "
|
f"{entity} reads {_signed(w)} of solar production "
|
||||||
"now.")
|
"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)
|
_price_check(notes, opts, running, prices_known)
|
||||||
return notes
|
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()
|
source = str(opts.get("meter_source") or "off").strip()
|
||||||
if source == "off":
|
if source == "off":
|
||||||
parts = [(opts.get("meter_entity"), 1.0)]
|
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)
|
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
||||||
if same and meter_now_w is not None:
|
if same and meter_now_w is not None:
|
||||||
_meter_line(notes, float(meter_now_w))
|
_meter_line(notes, float(meter_now_w))
|
||||||
else:
|
return float(meter_now_w)
|
||||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||||
"this meter itself, so Home shows its reading after "
|
"this meter itself, so Home shows its reading after "
|
||||||
"the restart.")
|
"the restart.")
|
||||||
return
|
return None
|
||||||
if entities is None:
|
if entities is None:
|
||||||
return
|
return None
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for entity, sign in parts:
|
for entity, sign in parts:
|
||||||
entity = str(entity or "").strip()
|
entity = str(entity or "").strip()
|
||||||
if not entity or entity not in entities:
|
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)
|
w, text = _reading(entities, entity)
|
||||||
if w is None:
|
if w is None:
|
||||||
_advise(notes, "warn", ("meter_source",),
|
_advise(notes, "warn", ("meter_source",),
|
||||||
f"{entity} reads {text} now, so the meter has no "
|
f"{entity} reads {text} now, so check it in Home "
|
||||||
"reading yet.")
|
"Assistant before you start.")
|
||||||
return
|
return None
|
||||||
total += sign * w
|
total += sign * w
|
||||||
if source == "off" and opts.get("meter_invert"):
|
if source == "off" and opts.get("meter_invert"):
|
||||||
total = -total
|
total = -total
|
||||||
_meter_line(notes, total)
|
_meter_line(notes, total)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def _meter_line(notes, w) -> None:
|
def _meter_line(notes, w) -> None:
|
||||||
@@ -1922,9 +1943,11 @@ def _meter_line(notes, w) -> None:
|
|||||||
_advise(notes, "ok", ("meter_source",), text)
|
_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:
|
if entities is None:
|
||||||
return
|
return None
|
||||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||||
if setpoint and setpoint in entities:
|
if setpoint and setpoint in entities:
|
||||||
_advise(notes, "ok", ("setpoint_entity",),
|
_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()
|
soc = str(entry.get("soc_entity") or "").strip()
|
||||||
if not soc:
|
if not soc:
|
||||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
_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:
|
elif soc in entities:
|
||||||
level, _unit = _reading(entities, soc)
|
level, _unit = _reading(entities, soc)
|
||||||
if level is None or not 0.0 <= level <= 100.0:
|
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:
|
if w is None:
|
||||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||||
f"power{who} reads nothing now, so check {batt}.")
|
f"power{who} reads nothing now, so check {batt}.")
|
||||||
return
|
return None
|
||||||
w = -w if entry.get("batt_invert") else w
|
w = -w if entry.get("batt_invert") else w
|
||||||
if abs(w) < IDLE_W:
|
if abs(w) < IDLE_W:
|
||||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
_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",),
|
_advise(notes, "info", ("batt_invert",),
|
||||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||||
"now, so check the inverter shows the same.")
|
"now, so check the inverter shows the same.")
|
||||||
|
return w
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _price_check(notes, opts, running, prices_known) -> 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.
|
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||||
"setup": uipage.setup_spec(),
|
"setup": uipage.setup_spec(),
|
||||||
"setup_needed": uipage.setup_needed(
|
"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)
|
return web.json_response(model)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
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:
|
class Store:
|
||||||
"""app/store.py's shape: a dict under .data, and set()."""
|
"""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.data = dict(data or {})
|
||||||
|
self.fresh = fresh
|
||||||
|
|
||||||
def set(self, key, value):
|
def set(self, key, value):
|
||||||
self.data[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")
|
print("\n4. first run: a new install opens the wizard, a working site not")
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
S = uipage.setup_needed
|
S = uipage.setup_needed
|
||||||
check("a new install: never set up, control never started or stopped",
|
check("a new install: no Store file at this boot, never set up, control "
|
||||||
S(NEW, {}, False) == "first_start"
|
"never started or stopped",
|
||||||
and S(NEW, {"phase": "idle", "sign_tripped": False}, False)
|
S(NEW, {}, False, fresh=True) == "first_start"
|
||||||
|
and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True)
|
||||||
== "first_start")
|
== "first_start")
|
||||||
check("a site that runs control, or ever ran or stopped it, is not sent to "
|
check("a site that runs control, or ever ran or stopped it, is not sent to "
|
||||||
"the wizard", S(NEW, {}, True) is None
|
"the wizard", S(NEW, {}, True, fresh=True) is None
|
||||||
and S(NEW, {"auto": True}, False) is None
|
and S(NEW, {"auto": True}, False, fresh=True) is None
|
||||||
and S(NEW, {"auto": False}, False) is None)
|
and S(NEW, {"auto": False}, False, fresh=True) is None)
|
||||||
check("a site the wizard saved is not sent again",
|
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 "
|
check("stored options with no inverter to control always open it, whatever "
|
||||||
"the Store says",
|
"the Store says",
|
||||||
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
|
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
|
||||||
@@ -345,7 +369,7 @@ async def _first(s, root, sup):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
_store = Store({"phase": "idle"})
|
_store = Store({"phase": "idle"}, fresh=True)
|
||||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
|
_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",
|
check("over HTTP, /config/model says a new install needs setup, with the spec",
|
||||||
_r["model"]["setup_needed"] == "first_start"
|
_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",
|
"opening by itself",
|
||||||
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
|
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
|
||||||
and _r["after"]["setup_needed"] is None)
|
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}),
|
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
|
||||||
auto=True), NEW, _first))
|
auto=True), NEW, _first))
|
||||||
check("a working site's /config/model says no", _r["model"]["setup_needed"]
|
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 "
|
and ("info", "The battery reads 800 W charging now, so check the "
|
||||||
"inverter shows the same.") in _c)
|
"inverter shows the same.") in _c)
|
||||||
check("a solar entity below zero, not flipped, looks reversed",
|
check("a solar entity below zero, not flipped, looks reversed",
|
||||||
("warn", "sensor.pv reads below zero now, so its solar production "
|
("warn", "sensor.pv reads below zero now, so turn on its “Reads "
|
||||||
"sign looks reversed.") in _c
|
"negative” switch in the Solar step.") in _c
|
||||||
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
|
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
|
||||||
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
|
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
|
||||||
entities=ROWS)))
|
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.")
|
"setpoint entity” for b (inverters.setpoint_entity) names.")
|
||||||
in _c2
|
in _c2
|
||||||
and ("ok", "The battery level for a reads 62 %.") 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 "
|
and ("warn", "There is no battery level entity for b, so pick one in "
|
||||||
"holds 0 W.") in _c2)
|
"step 2 or the add-on holds 0 W.") in _c2)
|
||||||
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
|
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
|
||||||
p1_export_entity="sensor.inv1_level")
|
p1_export_entity="sensor.inv1_level")
|
||||||
check("import and export are read as their difference",
|
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))
|
meter_now_w=250))
|
||||||
if "meter" in x] == ["info"])
|
if "meter" in x] == ["info"])
|
||||||
check("an unreadable meter is a warning",
|
check("an unreadable meter is a warning",
|
||||||
("warn", "sensor.grid reads unavailable now, so the meter has no "
|
("warn", "sensor.grid reads unavailable now, so check it in Home "
|
||||||
"reading yet.") in tiers(setup_checks(SITE, entities=dict(
|
"Assistant before you start.") in tiers(setup_checks(SITE, entities=dict(
|
||||||
ROWS, **{"sensor.grid": {"state": "unavailable",
|
ROWS, **{"sensor.grid": {"state": "unavailable",
|
||||||
"unit": "W"}}))))
|
"unit": "W"}}))))
|
||||||
_down = tiers(setup_checks(SITE, entities=None))
|
_down = tiers(setup_checks(SITE, entities=None))
|
||||||
check("Home Assistant down: one warning, nothing claimed ok about an entity",
|
check("Home Assistant down: red, because the power request entity cannot be "
|
||||||
_down[0] == ("warn", "Home Assistant is not answering, so no entity "
|
"checked (review R4), and nothing claimed ok about an entity",
|
||||||
"was checked yet.")
|
_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])
|
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), **{
|
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
|
||||||
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
|
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
|
||||||
_dyn = dict(SITE, price_provider="generic_dayahead")
|
_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 (
|
_rendered = [x for notes in (
|
||||||
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
|
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
|
||||||
setup_checks(SITE, entities=None), setup_checks(_hw, 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"),
|
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
|
||||||
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
|
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
|
||||||
for x in (n.text for n in notes)]
|
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])
|
in _dialogs[0])
|
||||||
check("the wizard defaults to shadow mode: the switch starts on",
|
check("the wizard defaults to shadow mode: the switch starts on",
|
||||||
"shadowOn: true" in JS)
|
"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(
|
_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))
|
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})",
|
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.
|
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
|
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
|
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
|
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
|
||||||
reversed meter (down).
|
reversed meter (down).
|
||||||
@@ -45,6 +45,8 @@ PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
|
|||||||
|
|
||||||
|
|
||||||
class Store(dict):
|
class Store(dict):
|
||||||
|
fresh = False # app/store.py: no state file at this boot
|
||||||
|
|
||||||
def set(self, k, v):
|
def set(self, k, v):
|
||||||
self[k] = v
|
self[k] = v
|
||||||
|
|
||||||
@@ -256,7 +258,9 @@ def controller(state):
|
|||||||
async def main(state, port, host="127.0.0.1"):
|
async def main(state, port, host="127.0.0.1"):
|
||||||
if state == "new":
|
if state == "new":
|
||||||
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
|
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())
|
stop=asyncio.Event())
|
||||||
c.auto = False
|
c.auto = False
|
||||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||||
|
|||||||
Reference in New Issue
Block a user