UX-13 P4: the setup wizard for installers
The Setup tab draws the Installer view's page model one step at a time, from each field's `wizard` step in app/uilayout.py: meter (with the kettle test), inverters and batteries, solar (optional), prices, peak limit (battery modes: "comes in a later update", OPT-11), check and start. It reuses P2's controls and saves through /config/validate and /config/save. No route was added. - First run: /config/model says `setup_needed` when the stored options name no power request entity, or on a new install (no `setup_done` and no `auto` in the Store, control not started). The page opens #setup only when opened with no route. The wizard's save writes `setup_done`. - Kettle test: GET ha/entities?entity= reads one entity now, in W. The change decides: up keeps meter_invert off, down turns it on; a net or import/export meter that goes down blocks the step with what to check. - Last step: validate.setup_checks() beside the refusals: the meter reads, each power request entity exists in HA (bad if not, SAFETY-77), battery levels and power with direction, solar sign, prices. Saves with shadow_mode on by default, then a confirmed restart naming the 0 W. - P2 test finding (a): a stale save lists only the keys the other surface moved (the blob handed out on open, by fingerprint, against stored now). - test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard can save; all accepted by Supervisor 2026.08.0 AppOptions (offline). - DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -13,6 +13,7 @@ CACHE_S = 30.0
|
||||
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
||||
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
||||
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
|
||||
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||
|
||||
|
||||
@@ -28,6 +29,18 @@ def row(state: dict) -> dict | None:
|
||||
"device_class": attrs.get("device_class")}
|
||||
|
||||
|
||||
def watts(r: dict | None):
|
||||
"""A power entity's reading in W, or None when it is not a number in a
|
||||
power unit (the setup wizard's kettle test)."""
|
||||
if not r or r.get("unit") not in _TO_W:
|
||||
return None
|
||||
try:
|
||||
value = float(r.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value * _TO_W[r["unit"]] if value == value else None
|
||||
|
||||
|
||||
def matches(r: dict, kind: str) -> bool:
|
||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||
@@ -54,13 +67,16 @@ class EntityCache:
|
||||
self.tried_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read."""
|
||||
async def get(self, max_age: float = CACHE_S
|
||||
) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read.
|
||||
`max_age` is how old a try may be before HA is asked again: the setup
|
||||
wizard's last step asks for a list at most a few seconds old."""
|
||||
async with self._lock: # one HA read however many pickers ask
|
||||
now = self.clock()
|
||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||
# not queue one 10 s read each behind this lock.
|
||||
if self.tried_at is None or now - self.tried_at >= CACHE_S:
|
||||
if self.tried_at is None or now - self.tried_at >= max_age:
|
||||
self.tried_at = now
|
||||
states = await self.hass.states()
|
||||
if states is not None:
|
||||
@@ -71,6 +87,14 @@ class EntityCache:
|
||||
return None, None
|
||||
return self.rows, now - self.read_at
|
||||
|
||||
async def reading(self, entity_id: str) -> dict:
|
||||
"""One entity read now, not from the list: the kettle test compares
|
||||
two readings seconds apart, which a 30 s list cannot. A read only."""
|
||||
r = row(await self.hass.state(entity_id)) if entity_id else None
|
||||
return {"entity_id": entity_id, "found": r is not None,
|
||||
"state": r and r["state"], "unit": r and r["unit"],
|
||||
"watts": watts(r)}
|
||||
|
||||
def peek(self) -> dict | None:
|
||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
||||
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
||||
|
||||
@@ -131,11 +131,16 @@ class SaveRefused(Exception):
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None,
|
||||
moved=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
||||
# against stored-now - when the caller knows what it opened. None
|
||||
# when it does not. `changed` also holds the operator's own edits.
|
||||
self.moved = None if moved is None else list(moved)
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
@@ -359,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
hass=None, base=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
@@ -382,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
|
||||
`base`, when given, is the blob the page opened (the one `expect` is the
|
||||
fingerprint of). A stale refusal then also names the keys that moved
|
||||
underneath it, so the page does not list the operator's own edits among
|
||||
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
@@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
moved = (changed_keys(base, stored)
|
||||
if isinstance(base, dict) and fingerprint(base) == expect
|
||||
else None)
|
||||
keys = changed if moved is None else moved
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
", ".join(keys) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||
moved=moved)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
|
||||
@@ -216,3 +216,11 @@ input:disabled{opacity:.6}
|
||||
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
||||
@media (max-width:640px){#savebar{bottom:62px}}
|
||||
#s-set{padding-bottom:80px}
|
||||
/* UX-13 P4: the setup wizard */
|
||||
.steps{display:flex;gap:4px;margin:4px 0 12px}
|
||||
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
|
||||
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
|
||||
.msg.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
|
||||
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
|
||||
.kettle .row{margin-top:8px}
|
||||
|
||||
@@ -332,7 +332,9 @@
|
||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||
|
||||
// Shared with settings.js, which loads after this file.
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) };
|
||||
// `first`: the page opened with no route, so settings.js may open the setup
|
||||
// wizard on a new install (UX-13 P4).
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
|
||||
@@ -117,11 +117,18 @@
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
|
||||
<div class="card">
|
||||
<h2 data-t="setup.name"></h2>
|
||||
<p class="small muted" data-t="setup.description"></p>
|
||||
<a class="btn" href="config" data-t="settings.open"></a>
|
||||
<div class="card" id="wz">
|
||||
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
|
||||
<div class="steps" id="wz-steps" aria-hidden="true"></div>
|
||||
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
|
||||
<div id="wz-banner" aria-live="polite"></div>
|
||||
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
|
||||
<div class="row spread" id="wz-nav" style="margin-top:14px">
|
||||
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
|
||||
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tiny muted" data-s="setup.description"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
ranges and choices all arrive as data. It posts the page model back; the
|
||||
add-on turns it into options and runs its own start checks, and nothing
|
||||
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||||
data is set as text, never as HTML. */
|
||||
data is set as text, never as HTML.
|
||||
P4: the Setup tab's wizard is the same page model, drawn one step at a
|
||||
time from each field's `wizard` step, and saved through the same routes. */
|
||||
(function () {
|
||||
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
||||
const W = ROOT.settings;
|
||||
@@ -28,7 +30,8 @@
|
||||
let DEVICE = "";
|
||||
let LIST = null; // the inverter list field
|
||||
let FILLED = {}; // field key -> the preset field that fills it
|
||||
let active = false;
|
||||
let view = null; // "settings" or "setup": the tab drawing V
|
||||
let STEPS = {}; // wizard step -> refusals that block it
|
||||
let uid = 0;
|
||||
const ROWS = [];
|
||||
const OPEN = new Set();
|
||||
@@ -60,7 +63,7 @@
|
||||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||||
}
|
||||
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||||
if (redraw || f.fills) render(); else paint();
|
||||
if (redraw || f.fills) redrawView(); else paint();
|
||||
queueValidate();
|
||||
}
|
||||
function findField(key) {
|
||||
@@ -290,6 +293,7 @@
|
||||
|
||||
/* ---------------- rows, cards and groups ---------------- */
|
||||
function keyText(f, card) {
|
||||
if (view === "setup") return ""; // the wizard shows no keys
|
||||
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||||
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||
}
|
||||
@@ -324,7 +328,7 @@
|
||||
} else {
|
||||
cards.forEach((c, i) => {
|
||||
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
||||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||||
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
|
||||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||||
@@ -393,7 +397,7 @@
|
||||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||
}
|
||||
function paint() {
|
||||
const q = $("#set-search").value.trim().toLowerCase();
|
||||
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
const placed = new Set();
|
||||
for (const r of ROWS) {
|
||||
r.visible = visible(r.f);
|
||||
@@ -451,7 +455,7 @@
|
||||
}
|
||||
function savebar() {
|
||||
const n = V ? dirtyCount() : 0;
|
||||
$("#savebar").hidden = !(active && n);
|
||||
$("#savebar").hidden = !(view === "settings" && n);
|
||||
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||
}
|
||||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||
@@ -459,17 +463,21 @@
|
||||
/* ---------------- talking to the add-on ---------------- */
|
||||
let vtimer = null, vseq = 0;
|
||||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||||
async function validate() {
|
||||
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
|
||||
// or null when it failed or a newer check overtook it.
|
||||
async function validate(extra) {
|
||||
const seq = ++vseq;
|
||||
try {
|
||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: V }) });
|
||||
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
|
||||
const b = await r.json();
|
||||
if (seq !== vseq) return;
|
||||
if (seq !== vseq) return null;
|
||||
MSG = b.messages || [];
|
||||
DEVICE = b.device_note || "";
|
||||
STEPS = b.steps || {};
|
||||
paint();
|
||||
} catch (e) { /* the next change tries again */ }
|
||||
return b;
|
||||
} catch (e) { return null; /* the next change tries again */ }
|
||||
}
|
||||
function banner(level, title, sub, button, onClick) {
|
||||
const host = $("#set-banner");
|
||||
@@ -513,7 +521,7 @@
|
||||
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
||||
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||||
else banner();
|
||||
render();
|
||||
if (view === "setup") drawStep(); else render();
|
||||
validate();
|
||||
}
|
||||
function firstBad() {
|
||||
@@ -524,6 +532,20 @@
|
||||
const input = r.el.querySelector("input,select,button");
|
||||
if (input) input.focus({ preventScroll: true });
|
||||
}
|
||||
// The one save path, for the Installer view and the wizard. After a stale
|
||||
// refusal the page holds the new token, so a second press saves.
|
||||
async function postSave(extra) {
|
||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||||
return { r, b };
|
||||
}
|
||||
// The settings another surface changed (not this page's own edits).
|
||||
function staleText(b) {
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
|
||||
}
|
||||
async function save() {
|
||||
const btn = $("#sb-save");
|
||||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||
@@ -531,14 +553,10 @@
|
||||
clearTimeout(vtimer);
|
||||
await validate();
|
||||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: V, fingerprint: FP }) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
const { r, b } = await postSave();
|
||||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||
if (r.status === 409 && b.kind === "stale") {
|
||||
FP = b.fingerprint || FP;
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
|
||||
banner("bad", W.stale.name, staleText(b));
|
||||
return;
|
||||
}
|
||||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||||
@@ -552,10 +570,234 @@
|
||||
await load(); toast(W.toast.discarded);
|
||||
});
|
||||
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
||||
|
||||
/* ---------------- the setup wizard (P4) ---------------- */
|
||||
const WS = W.setup;
|
||||
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
|
||||
function redrawView() { if (view === "setup") drawStep(); else render(); }
|
||||
function allFields() {
|
||||
const out = [];
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
|
||||
return out;
|
||||
}
|
||||
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
|
||||
function watts(w, signed) {
|
||||
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return (signed && w < 0 ? "-" : "") + n + " W";
|
||||
}
|
||||
function line(tier, text) {
|
||||
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
|
||||
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
|
||||
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
|
||||
}
|
||||
|
||||
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
|
||||
// uipage.kettle() (test_setup.py grades that one) on the page.
|
||||
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
|
||||
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
|
||||
function kettleOutcome(src, before, after) {
|
||||
const d = after - before;
|
||||
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
|
||||
const o = src[d > 0 ? "up" : "down"];
|
||||
return { say: o.say, sets: o.sets, d };
|
||||
}
|
||||
function kettleOk() {
|
||||
const src = kettleSource();
|
||||
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
|
||||
}
|
||||
async function readMeter(src) {
|
||||
let total = 0;
|
||||
for (const [key, sign] of src.read) {
|
||||
if (!V[key]) return null;
|
||||
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
|
||||
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
|
||||
if (!r || r.watts == null) return null;
|
||||
total += sign * r.watts;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function kettleBox() {
|
||||
const src = kettleSource();
|
||||
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
|
||||
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
|
||||
const sig = kettleSig(src);
|
||||
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
|
||||
const k = WZ.kettle || { sig };
|
||||
box.append(h("div", { class: "help" }, WS.kettle.description));
|
||||
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn" }[k.say] || "bad";
|
||||
const words = k.say ? WS.kettle[k.say] : k.before != null ? WS.kettle.before : null;
|
||||
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
|
||||
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
|
||||
const busy = (b) => { b.disabled = true; };
|
||||
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
|
||||
read.addEventListener("click", async () => {
|
||||
busy(read);
|
||||
const sig = kettleSig(src); // the entity may have changed since drawing
|
||||
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
|
||||
const w = await readMeter(src);
|
||||
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
|
||||
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w };
|
||||
else {
|
||||
const o = kettleOutcome(src, k.before, w);
|
||||
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null };
|
||||
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||||
}
|
||||
drawStep();
|
||||
});
|
||||
const row = h("div", { class: "row" }, read);
|
||||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
||||
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true }; drawStep();
|
||||
} }, WS.kettle.skip));
|
||||
box.append(row);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Step 6: the shadow switch, the checks, and Save and restart.
|
||||
function applyShadow() {
|
||||
const s = M.setup.shadow;
|
||||
V[s.field] = WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
|
||||
}
|
||||
function shadowRow() {
|
||||
const lid = "wzs";
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
||||
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; applyShadow(); queueValidate(); drawStep(); });
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
|
||||
h("div", { class: "ctl" }, b));
|
||||
}
|
||||
async function runChecks() {
|
||||
const list = $("#wz-checks");
|
||||
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate({ setup: true });
|
||||
WZ.checked = b;
|
||||
if (!list || !list.isConnected) return b;
|
||||
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
|
||||
const items = [], seen = new Set();
|
||||
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
|
||||
const bad = (b.messages || []).filter((m) => m.tier === "bad");
|
||||
if (!bad.length) add("ok", WS.check.pass.description);
|
||||
for (const m of bad) add("bad", m.text);
|
||||
for (const m of b.setup || []) add(m.tier, m.text);
|
||||
if (b.device_note) add("info", b.device_note);
|
||||
const src = kettleSource();
|
||||
if (!src) add("info", WS.kettle.self.description);
|
||||
else if (WZ.kettle && WZ.kettle.skipped) add("warn", WS.kettle.skipped.description);
|
||||
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||||
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
|
||||
WZ.blocked = items.some(([t]) => t === "bad");
|
||||
return b;
|
||||
}
|
||||
function finalBox() {
|
||||
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
|
||||
setTimeout(runChecks, 0);
|
||||
return [h("div", { class: "subhead" }, WS.check.name),
|
||||
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
|
||||
shadowRow()];
|
||||
}
|
||||
function wzBanner(level, title, sub, button, onClick) {
|
||||
const host = $("#wz-banner");
|
||||
if (!title) { host.replaceChildren(); return; }
|
||||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||
}
|
||||
async function finish() {
|
||||
const b = await runChecks();
|
||||
if (!b) { toast(WS.check.failed.description); return; }
|
||||
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
||||
const shadow = V[M.setup.shadow.field] === M.setup.shadow.on;
|
||||
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
|
||||
wzBanner();
|
||||
let res;
|
||||
try { res = await postSave({ setup: "done" }); } catch (e) { toast(W.toast.failed); return; }
|
||||
const { r, b: body } = res;
|
||||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||||
if (r.status === 422) {
|
||||
MSG = body.messages || []; STEPS = body.steps || {};
|
||||
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
|
||||
if (first) WZ.step = first;
|
||||
drawStep(); toast(W.toast.blocked.description); firstBad();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { toast(W.toast.failed); return; }
|
||||
let restarted = true;
|
||||
try {
|
||||
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "restart" }) });
|
||||
restarted = rr.ok;
|
||||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
|
||||
drawStep();
|
||||
if (!restarted) await load("saved"); // still running: read what is stored
|
||||
});
|
||||
}
|
||||
function drawDone() {
|
||||
const w = WS[WZ.done];
|
||||
$("#wz-count").textContent = "";
|
||||
$("#wz-steps").replaceChildren();
|
||||
$("#wz-desc").textContent = "";
|
||||
$("#wz-nav").hidden = true;
|
||||
ROWS.length = 0;
|
||||
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
|
||||
else wzBanner("ok", w.name, w.description);
|
||||
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
|
||||
}
|
||||
function drawStep() {
|
||||
if (!M) return;
|
||||
if (WZ.done) { drawDone(); return; }
|
||||
const S = M.setup, n = WZ.step, words = WS.steps[n];
|
||||
$("#wz-nav").hidden = false;
|
||||
$("#wz-title").textContent = words.name;
|
||||
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
|
||||
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
|
||||
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
|
||||
$("#wz-desc").textContent = words.description;
|
||||
ROWS.length = 0;
|
||||
const body = [];
|
||||
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
|
||||
if (n === S.final) applyShadow();
|
||||
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
|
||||
if (n === 1) body.push(kettleBox());
|
||||
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
|
||||
if (n === S.final) body.push(...finalBox());
|
||||
$("#wz-body").replaceChildren(...body);
|
||||
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
|
||||
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
|
||||
paint();
|
||||
}
|
||||
$("#wz-back").addEventListener("click", () => {
|
||||
if (WZ.step === 1) { location.hash = "#home"; return; }
|
||||
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
|
||||
});
|
||||
$("#wz-next").addEventListener("click", async () => {
|
||||
if (!M) return;
|
||||
const btn = $("#wz-next");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const n = WZ.step;
|
||||
if (n === M.setup.final) { await finish(); return; }
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate();
|
||||
if (!b) { toast(W.toast.failed); return; }
|
||||
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
|
||||
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.addEventListener("ux13:tab", (e) => {
|
||||
active = e.detail === "settings";
|
||||
if (active && !M) load(); else savebar();
|
||||
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
|
||||
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
|
||||
});
|
||||
// app.js chose the first tab before this file ran.
|
||||
if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); }
|
||||
const sel = document.querySelector(".tab[aria-selected=true]");
|
||||
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
|
||||
// First run: opened with no route, on a site the add-on says is new
|
||||
// (uipage.setup_needed), the page goes to Setup by itself.
|
||||
else if (window.UX13.first) {
|
||||
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -80,8 +80,7 @@
|
||||
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
||||
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
|
||||
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
|
||||
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"},
|
||||
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
|
||||
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
||||
},
|
||||
"choices_later": {
|
||||
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
|
||||
@@ -148,7 +147,7 @@
|
||||
"blocked": {"description": "Fix the settings marked in red, then save again."},
|
||||
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
|
||||
},
|
||||
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note"},
|
||||
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
|
||||
"derive": {
|
||||
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
||||
"no_inverter": {"description": "Add at least one inverter."},
|
||||
@@ -157,6 +156,59 @@
|
||||
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
|
||||
"no_price": {"description": "Enter a price per kWh."},
|
||||
"unknown": {"description": "The page cannot store this choice, so reload the page."}
|
||||
},
|
||||
"setup": {
|
||||
"name": "Setup",
|
||||
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
|
||||
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
|
||||
"step": "Step {n} of {total}",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"skip": "Next, or skip",
|
||||
"finish": "Save and restart",
|
||||
"not_now": "Not now",
|
||||
"steps": {
|
||||
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
|
||||
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
|
||||
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
|
||||
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
|
||||
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
|
||||
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
|
||||
},
|
||||
"modes_later": {"description": "Choosing a mode for each battery comes in a later update."},
|
||||
"kettle": {
|
||||
"name": "Check the direction",
|
||||
"description": "Read the meter, switch on a kettle, then read the meter again.",
|
||||
"read": "Read the meter",
|
||||
"again": "Read again",
|
||||
"skip": "Skip this test",
|
||||
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
|
||||
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
|
||||
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
|
||||
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
|
||||
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
|
||||
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
|
||||
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
|
||||
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
|
||||
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
|
||||
"tested": {"description": "The meter direction was tested with a kettle."},
|
||||
"needed": {"description": "Do the kettle test first, or skip it."}
|
||||
},
|
||||
"check": {
|
||||
"name": "What the add-on checked",
|
||||
"running": {"description": "Checking with the add-on's own start checks."},
|
||||
"pass": {"description": "The add-on's start checks pass."},
|
||||
"failed": {"description": "The check did not run, so try again."},
|
||||
"again": "Check again",
|
||||
"blocked": {"description": "Fix the items marked in red, then check again."}
|
||||
},
|
||||
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
||||
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
||||
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
||||
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
|
||||
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
|
||||
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
|
||||
"home": "Open Home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,34 @@ GROUPS = (
|
||||
), support_only=True),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
|
||||
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
|
||||
# --------------------------------------------------------------------------- #
|
||||
WIZARD_STEPS = 6
|
||||
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
|
||||
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
|
||||
|
||||
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
|
||||
# read before and after a kettle is switched on, each with the sign it adds to
|
||||
# the grid reading, and what each direction means as (outcome, fields it
|
||||
# sets). Sets None: the options cannot fix it, so the step is not passed. A
|
||||
# source not listed is read by the add-on itself, so the page cannot test it.
|
||||
KETTLE_SOURCE = "meter_source"
|
||||
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
|
||||
KETTLE = {
|
||||
"off": ((("meter_entity", 1),),
|
||||
{"up": ("ok", {"meter_invert": False}),
|
||||
"down": ("flip", {"meter_invert": True})}),
|
||||
"ha_signed": ((("p1_net_entity", 1),),
|
||||
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
|
||||
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
|
||||
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
|
||||
}
|
||||
# Step 6 starts the site in shadow mode unless the installer switches it off:
|
||||
# (field, value when on, value when off).
|
||||
SHADOW = ("shadow_mode", "on", "auto")
|
||||
|
||||
# Option keys the page never shows. uimodel.py writes each from page fields.
|
||||
DERIVED = {
|
||||
"setpoint_entity": "inverters", # one inverter, stored flat
|
||||
|
||||
@@ -17,6 +17,7 @@ import re
|
||||
from . import uilayout as L
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
|
||||
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||
"en.json")
|
||||
@@ -101,6 +102,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
"help": text, "unit": fld.unit, "scale": fld.scale,
|
||||
"step": fld.step, "kind": fld.kind,
|
||||
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
||||
"wizard": fld.wizard,
|
||||
"type": value_type, "optional": not node.get("required", False),
|
||||
# A choice mirrored into a field also shows (and finds) its keys.
|
||||
"writes": sorted(k for k, how in L.DERIVED.items()
|
||||
@@ -278,3 +280,78 @@ def fields_of(keys, model) -> list:
|
||||
"""The page fields that show these option keys (a stale save's list)."""
|
||||
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
|
||||
if f))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Store keys (app/store.py), not options. SETUP_DONE is written when the
|
||||
# wizard saves. STARTED is the one control's Start and Stop write.
|
||||
SETUP_DONE = "setup_done"
|
||||
STARTED = "auto"
|
||||
|
||||
|
||||
def setup_needed(stored: dict, store_data, started) -> str | None:
|
||||
"""Why the page opens the wizard by itself, or None.
|
||||
|
||||
"no_power_request": the stored options name no inverter to control, so
|
||||
the add-on cannot start on them. "first_start": this /data has never been
|
||||
through the wizard and control was never started or stopped, which is a
|
||||
new install: config.yaml ships `auto_start` off, so it comes up stopped.
|
||||
A site that runs, or ever ran, control is never sent to the wizard.
|
||||
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
||||
whether control is started now."""
|
||||
if not power_request_entities(stored or {}):
|
||||
return "no_power_request"
|
||||
if store_data is None:
|
||||
return None
|
||||
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
||||
return None
|
||||
return "first_start"
|
||||
|
||||
|
||||
def setup_spec() -> dict:
|
||||
"""What the wizard needs beyond each field's step: read off the table."""
|
||||
kettle = {}
|
||||
for source, (read, outcomes) in L.KETTLE.items():
|
||||
kettle[source] = {"read": [[f, sign] for f, sign in read]}
|
||||
for way, (say, sets) in outcomes.items():
|
||||
kettle[source][way] = {"say": say, "sets": sets}
|
||||
field, on, off = L.SHADOW
|
||||
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
|
||||
"optional": list(L.OPTIONAL_STEPS),
|
||||
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
|
||||
"sources": kettle},
|
||||
"shadow": {"field": field, "on": on, "off": off}}
|
||||
|
||||
|
||||
def kettle(source, before_w, after_w):
|
||||
"""The kettle test's verdict, as the page reaches it (settings.js
|
||||
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
|
||||
for a source the page cannot read."""
|
||||
spec = L.KETTLE.get(source)
|
||||
if spec is None:
|
||||
return None
|
||||
change = float(after_w) - float(before_w)
|
||||
if abs(change) < L.KETTLE_MIN_W:
|
||||
return "none", None, change
|
||||
say, sets = spec[1]["up" if change > 0 else "down"]
|
||||
return say, (dict(sets) if sets is not None else None), change
|
||||
|
||||
|
||||
def step_of(field_key, model) -> int:
|
||||
"""The wizard step a sentence on this page field blocks: the field's own
|
||||
step while it is shown, else the last step, which lists everything."""
|
||||
fld = L.field(field_key) if field_key else None
|
||||
if fld is None or not fld.wizard or not L.visible(fld, model):
|
||||
return L.FINAL_STEP
|
||||
return fld.wizard
|
||||
|
||||
|
||||
def step_problems(messages, model) -> dict:
|
||||
"""{"1": n, ...}: how many refusals block each wizard step."""
|
||||
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
|
||||
for m in messages:
|
||||
if m.get("tier") == "bad":
|
||||
out[str(step_of(m.get("field"), model))] += 1
|
||||
return out
|
||||
|
||||
@@ -374,7 +374,8 @@ class Note(NamedTuple):
|
||||
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
||||
add-on starts. `keys` are the options it is about, so the page can show it
|
||||
beside them."""
|
||||
tier: str # "warn" or "info"
|
||||
tier: str # "warn" or "info"; the wizard's checks
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
|
||||
@@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
_entity_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
@@ -1769,6 +1759,230 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
return notes
|
||||
|
||||
|
||||
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
"""A note for each entity these options read that Home Assistant does not
|
||||
have, or that reads in kW where watts are taken. A missing entity whose
|
||||
key is in `bad` is a "bad" note, which blocks the setup wizard."""
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard's last step
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Below this a battery or solar reading counts as idle for a sign check.
|
||||
IDLE_W = 50.0
|
||||
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
||||
# MQTT) is only known live when the running options read the same one.
|
||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
||||
|
||||
|
||||
def power_request_entities(opts: dict) -> list:
|
||||
"""The power request entity of each inverter these options name: the flat
|
||||
one, or each entry's. Blank ones are left out."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
values = [opts.get("setpoint_entity")]
|
||||
else:
|
||||
values = [e.get("setpoint_entity") for e in entries
|
||||
if isinstance(e, dict)]
|
||||
return [str(v).strip() for v in values
|
||||
if isinstance(v, str) and v.strip()]
|
||||
|
||||
|
||||
def _reading(entities, entity, convert=False):
|
||||
"""(number, unit) of an entity's state, or (None, state text). The number
|
||||
is as the add-on reads it: raw for the meter and battery power, which it
|
||||
takes as watts, and in W for solar (`convert`), which pv.py converts."""
|
||||
row = (entities or {}).get(entity) or {}
|
||||
unit = str(row.get("unit") or "")
|
||||
try:
|
||||
value = float(row.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None, str(row.get("state") or "nothing")
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return None, str(row.get("state"))
|
||||
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
|
||||
|
||||
|
||||
def _signed(w) -> str:
|
||||
return ("-" if w < 0 else "") + messages.watts(w)
|
||||
|
||||
|
||||
def _cards(opts: dict) -> list:
|
||||
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [("", {k: opts.get(k) for k in (
|
||||
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
|
||||
out = []
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
name = _unit_label(opts, i).strip().strip("()")
|
||||
out.append((f" for {name}" if name else "", entry))
|
||||
return out
|
||||
|
||||
|
||||
def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
meter_now_w=None, prices_known=None) -> list[Note]:
|
||||
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
|
||||
|
||||
Live checks on the proposed options: the meter reads, each power request
|
||||
entity exists in Home Assistant (a write to a missing one is answered 200,
|
||||
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
||||
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
||||
request entity, which the wizard will not save. Reads nothing itself:
|
||||
`entities` is Home Assistant's list by id (None when it did not answer),
|
||||
`running` the options this process started with, `meter_now_w` its meter
|
||||
reading and `prices_known` how many of today's hours have a price.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is None:
|
||||
_advise(notes, "warn", (), "Home Assistant is not answering, so "
|
||||
"no entity was checked yet.")
|
||||
else:
|
||||
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
||||
_meter_check(notes, opts, entities, running, meter_now_w)
|
||||
for who, entry in _cards(opts):
|
||||
_card_checks(notes, who, entry, entities)
|
||||
if entities is not None:
|
||||
flipped = pv.inverted(opts)
|
||||
for entity in pv.entities(opts):
|
||||
w, _unit = _reading(entities, entity, convert=True)
|
||||
if entity not in entities or w is None:
|
||||
continue
|
||||
if w < -IDLE_W and entity not in flipped:
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so its solar "
|
||||
"production sign looks reversed.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
|
||||
def _meter_check(notes, opts, entities, running, meter_now_w) -> None:
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
if source == "off":
|
||||
parts = [(opts.get("meter_entity"), 1.0)]
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
parts = [(opts.get("p1_net_entity"), 1.0)]
|
||||
elif source == SOURCE_HA:
|
||||
parts = [(opts.get("p1_import_entity"), 1.0),
|
||||
(opts.get("p1_export_entity"), -1.0)]
|
||||
else:
|
||||
same = isinstance(running, dict) and all(
|
||||
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
||||
if same and meter_now_w is not None:
|
||||
_meter_line(notes, float(meter_now_w))
|
||||
else:
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
return
|
||||
if entities is None:
|
||||
return
|
||||
total = 0.0
|
||||
for entity, sign in parts:
|
||||
entity = str(entity or "").strip()
|
||||
if not entity or entity not in entities:
|
||||
return # a refusal or a missing-entity note says so
|
||||
w, text = _reading(entities, entity)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so the meter has no "
|
||||
"reading yet.")
|
||||
return
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
total = -total
|
||||
_meter_line(notes, total)
|
||||
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities) -> None:
|
||||
if entities is None:
|
||||
return
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so the add-on holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
return
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
|
||||
|
||||
def _price_check(notes, opts, running, prices_known) -> None:
|
||||
provider = str(opts.get("price_provider") or "off").strip()
|
||||
profile = str(opts.get("static_profile") or "").strip()
|
||||
if provider in ("", "off"):
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -28,19 +28,23 @@ do to the inverter through this panel is exactly what `/action` offered before
|
||||
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
||||
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
|
||||
of Home Assistant's entity list (app/haentities.py) and writes nothing.
|
||||
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
||||
not options is a Store key, `setup_done`, written when the wizard's save
|
||||
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, selfconfig, uipage
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .validate import config_notes
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
@@ -103,6 +107,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
controller.hass.token)
|
||||
entities = EntityCache(controller.hass)
|
||||
index_page = page()
|
||||
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
|
||||
# save can name only the keys that moved underneath the page (finding a).
|
||||
handed: dict = {}
|
||||
|
||||
def hand_out(options):
|
||||
fp = selfconfig.fingerprint(options)
|
||||
handed.pop(fp, None)
|
||||
handed[fp] = options
|
||||
while len(handed) > 16:
|
||||
handed.pop(next(iter(handed)))
|
||||
return fp
|
||||
|
||||
def store_data():
|
||||
store = getattr(controller, "store", None)
|
||||
data = getattr(store, "data", None)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=index_page, content_type="text/html")
|
||||
@@ -165,7 +185,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"fingerprint": hand_out(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
@@ -176,6 +196,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"layout": uipage.layout(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang),
|
||||
"ui": options_to_ui(options),
|
||||
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||
"setup": uipage.setup_spec(),
|
||||
"setup_needed": uipage.setup_needed(
|
||||
options, store_data(), bool(getattr(controller, "auto", False))),
|
||||
})
|
||||
return web.json_response(model)
|
||||
|
||||
@@ -213,8 +237,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
if model is not None:
|
||||
payload["messages"] = uipage.anchors(problems, notes, model,
|
||||
_keys_of(options))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
"""The wizard's live checks: a list at most a few seconds old, and
|
||||
what the running add-on already knows (its meter, today's prices)."""
|
||||
rows, age = await entities.get(max_age=5.0)
|
||||
if age is not None and age >= 30.0:
|
||||
rows = None # Home Assistant is not answering
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
meter_now_w=getattr(controller, "grid", None),
|
||||
prices_known=known)
|
||||
|
||||
def _keys_of(options):
|
||||
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
||||
# sound only because the page posts the WHOLE blob it read - which is
|
||||
@@ -249,21 +292,26 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
hass=controller.hass)
|
||||
hass=controller.hass,
|
||||
base=handed.get(expect))
|
||||
except selfconfig.SaveRefused as refused:
|
||||
if refused.kind == "stale":
|
||||
stored = refused.stored or {}
|
||||
# The fields to look at are the ones the OTHER surface moved,
|
||||
# not this page's own edits (UX-13 P2 test, finding a).
|
||||
moved = (refused.changed if refused.moved is None
|
||||
else refused.moved)
|
||||
return web.json_response(
|
||||
{"kind": "stale", "changed": refused.changed,
|
||||
"changed_fields": (uipage.fields_of(refused.changed,
|
||||
model)
|
||||
"moved": refused.moved,
|
||||
"changed_fields": (uipage.fields_of(moved, model)
|
||||
if model is not None else []),
|
||||
"stored": stored,
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
# made in front of what changed.
|
||||
"fingerprint": selfconfig.fingerprint(stored)},
|
||||
"fingerprint": hand_out(stored)},
|
||||
status=409)
|
||||
by_key, unanchored = problems_by_field(refused.problems,
|
||||
_keys_of(options))
|
||||
@@ -272,6 +320,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
if model is not None:
|
||||
payload["messages"] = uipage.anchors(refused.problems, [],
|
||||
model, _keys_of(options))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"],
|
||||
model)
|
||||
return web.json_response(payload, status=422)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
@@ -279,6 +329,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
controller.log_event("configuration saved from the panel - "
|
||||
"not applied until the add-on restarts",
|
||||
hint=messages.hint("settings_saved"))
|
||||
# `handed` keeps `expect`: another page may have opened the same blob.
|
||||
store = getattr(controller, "store", None)
|
||||
if (model is not None and body.get("setup") == "done"
|
||||
and store is not None):
|
||||
store.set(uipage.SETUP_DONE,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
@@ -310,6 +366,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"""UX-13: Home Assistant's entities for the picker. A read only, and
|
||||
never an error: when Home Assistant is down the list says so."""
|
||||
kind = str(request.query.get("kind", "") or "")[:16]
|
||||
one = str(request.query.get("entity", "") or "")[:255]
|
||||
if one:
|
||||
# UX-13 P4's kettle test: one entity, read now.
|
||||
return web.json_response(await entities.reading(one))
|
||||
return web.json_response(await entities.payload(kind))
|
||||
|
||||
app = web.Application()
|
||||
|
||||
Reference in New Issue
Block a user