UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s

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:
2026-09-28 05:31:18 +02:00
co-authored by Claude Opus 5.5
parent 515c11b380
commit be57b87c34
19 changed files with 1885 additions and 59 deletions
@@ -66,6 +66,10 @@ What UX-02 made authoritative stays authoritative:
to the existing `/config/validate` and `/config/save`, which turn it into
options with `ui_to_options()` before the shipped validator runs. No route
was added.
- P4: the setup wizard draws the same model by each field's `wizard` step.
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
names no key. See [[UX-13-setup-wizard]].
## 4. What this costs
@@ -0,0 +1,93 @@
# UX-13 P4 — the setup wizard, and when it opens by itself
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
## 1. What the wizard is
The Setup tab draws the Installer view's page model one step at a time: a
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
controls, `/config/validate` and `/config/save`. No route was added.
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
yet (OPT-11); it says they come later.
A step moves on when no refusal is anchored to one of its shown fields
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
no field at all blocks the last step, which lists every refusal.
## 2. When it opens by itself (`uipage.setup_needed`)
The page opens `#setup` only when it is opened with no route, and only when
`/config/model` gives a reason:
- **`no_power_request`**: the stored options name no inverter to control.
- **`first_start`**: the Store has no `setup_done` and no `auto` key, and
control is not started. `auto` is the key Start/Stop writes, and
config.yaml ships `auto_start` off (the same "fresh install" reading as
app/shadow.py's `auto` default), so this is a site that has never run or
been stopped by hand and never finished the wizard.
A site that runs control, or ever started or stopped it, is never sent to the
wizard. Without a Store the answer is no. The wizard's save writes
`setup_done` (an ISO time) to the Store. A save from the Installer view does
not.
**Not decided here, for the owner:** the add-on exits 78 before it serves the
page when the stored options name no power request entity, which is what a
brand-new install ships (config.yaml's `setpoint_entity: ""`). So on a truly
new install the installer fills in "Inverter setpoint entity" on the
Configuration tab first, and the wizard opens on the next page visit.
Serving a setup-only page from a refused configuration would change the CFG-01
startup gate and UX-02 §4 ("the panel is not available when it is most
needed"), so it is left as an open question.
## 3. The kettle test and the sign checks
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
`hass.state()` read, in W) before and after a kettle is switched on. The
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
off, a fall turns it on. For a net entity or import and export, a fall cannot
be fixed by an option, so the step is not passed and the page says what to
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
the page, so the test is skipped and Home shows the direction after the
restart. The installer may skip the test; the last step then warns.
The battery and solar signs cannot be forced from the page, which never
commands an inverter. The last step reports each battery's reading with its
direction ("reads 800 W charging now, so check the inverter shows the same")
and warns when a solar entity reads below zero without its "reads negative"
switch.
## 4. The last step
`validate.setup_checks()` sits beside the rules because it names option keys.
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
5 s old, the running options, the running meter reading and today's price
count. Tiers are ok, info, warn and bad. A power request entity missing from
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
and only the entity list can tell. The wizard does not save while anything is
bad. It then saves through P2's path (`setup: "done"`) and asks for the
restart as the second, confirmed request, with a dialog that names the 0 W.
The shadow switch is on by default and writes `shadow_mode: on`, which holds
every inverter at 0 W after a restart even if control is started. Off keeps the
stored value, or `auto` if that was `on`.
## 5. The stale save lists only what moved (P2 test, finding a)
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
stale save the add-on compares that blob with what is stored now, so the banner
lists only the other surface's changes. `changed` (stored against the posted
blob) is unchanged for the classic page's "theirs → mine" list. When the
fingerprint was not handed out by this process, it falls back to `changed`.
## 6. How it is held
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
it), step blocking, the first-run rule on new and TEST-23 sites, the live
checks, the save through the P2 path, the stale fix, and the words.
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
install and three TEST-23 sites (88 accepted by the shipped validator). At P4
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
keys each.
+26
View File
@@ -3221,6 +3221,32 @@ action log (`Controller.events`). The page no longer shows that log, but the
page and add `status` to its address. The action-log tables in this document
refer to it.*
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
inverters and batteries, solar (optional), prices, the peak limit, and a last
check. Each step shows the same fields as Settings, and nothing is saved until
the last step.
- **The kettle test** on the meter step reads the meter, then reads it again
after you switch on a kettle. If the reading goes down, the add-on flips its
sign for you; with a net or import/export meter it tells you what to change.
- **The last step** runs the add-on's own start checks and reads Home
Assistant: the meter reading, that each power request entity exists, the
battery levels, and whether prices are known. A missing power request entity
is red, so it cannot be saved.
- **Save and restart** saves, then restarts the add-on. Every inverter is set
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
stays at 0 W until you turn shadow mode off under Settings.
Setup opens by itself when the page is opened on a new install: control was
never started or stopped and Setup never finished, or the stored settings name
no inverter to control. A running site is never sent there. The add-on does
not start with no power request entity at all, so on a brand-new install fill
in "Inverter setpoint entity" on the Configuration tab first.
*For installers: card UX-13, and `DECISIONS/UX-13-setup-wizard.md`.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
+27 -3
View File
@@ -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:
+19 -4
View File
@@ -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 "
+8
View File
@@ -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}
+3 -1
View File
@@ -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")];
+11 -4
View File
@@ -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>
+262 -20
View File
@@ -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"; });
}
})();
+55 -3
View File
@@ -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"
}
}
}
+28
View File
@@ -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
+77
View File
@@ -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
+227 -13
View File
@@ -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 == "":
+66 -6
View File
@@ -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()
+1 -1
View File
@@ -920,7 +920,7 @@ DATA = {
"price_history.py:messages.count(len(points), 'price')",
"price_history.py:err",
"selfconfig.py:messages.count(len(problems), 'problem')",
"selfconfig.py:', '.join(changed) or 'none this page also edited'",
"selfconfig.py:', '.join(keys) or 'none this page also edited'",
"static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw",
"tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month",
# validate.py: option values and keys (the refusals are part 3's)
+4 -2
View File
@@ -429,9 +429,11 @@ async def _stale(s, root, sup):
_r, _sup = asyncio.run(session(TWO, _stale))
check("409 with the page fields to look at, and nothing written",
check("409 with the page fields to look at, and nothing written. Only the "
"field the other surface changed, not the page's own edit (P4, P2 "
"test finding a)",
_r[0] == 409 and _r[1]["kind"] == "stale"
and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == []
and _r[1]["changed_fields"] == ["peak_cap_w"] and _sup.writes == []
and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options))
# --------------------------------------------------------------------------- #
+694
View File
@@ -0,0 +1,694 @@
"""UX-13 P4: the setup wizard. `python3 test_setup.py`
The wizard is the Installer view's page model drawn one step at a time from
each field's `wizard` step (app/uilayout.py), saved through the same routes.
Part 1 checks the steps and the tables the page reads. Part 2 the kettle
test's sign logic. Part 3 which refusals block which step. Part 4 the
first-run rule on new and existing sites. Part 5 the last step's live checks.
Part 6 the save through the P2 path, and every blob the wizard can produce
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
save names only what changed elsewhere (P2 test, finding a). Part 8 the
words. The headless-browser click-through is in the PR.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import copy
import io
import json
import logging
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) # the validator's own log lines
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
import ux13_wizard_blobs as B # noqa: E402
from app import uilayout as L, uimodel as U, uipage, web # noqa: E402
from app.validate import setup_checks, validate_startup_config # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
DEFAULTS = D.defaults(D.CONFIG)
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
UI_TEXT = uipage.ui_text()
WS = UI_TEXT["settings"]["setup"]
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
# --------------------------------------------------------------------------- #
print("1. six steps, drawn from the layout table")
# --------------------------------------------------------------------------- #
LAYOUT = uipage.layout(SCHEMA, TRANSLATIONS)
TOP = [f for g in LAYOUT["groups"] for p in g["parts"] for f in p["fields"]]
BY_STEP = {n: [f["key"] for f in TOP if f["wizard"] == n]
for n in range(1, L.WIZARD_STEPS + 1)}
print(f" {BY_STEP}")
check("every field reaches the page with its wizard step",
all("wizard" in f for f in TOP)
and all(s["wizard"] == 2 for f in TOP for s in f.get("fields", [])))
check("the mockup's six steps: meter, inverters, solar, prices, peak limit, "
"check and start",
BY_STEP[1][0] == "meter_source" and BY_STEP[2] == ["inverters"]
and BY_STEP[3] == ["ui.solar"] and BY_STEP[4][0] == "ui.contract"
and BY_STEP[5] == ["peak_cap_w", "peak_forecast_entity"]
and BY_STEP[6] == [L.SHADOW[0]]
and [WS["steps"][str(n)]["name"] for n in range(1, 7)]
== ["Your meter", "Inverters and batteries", "Solar", "Prices",
"Peak limit", "Check and start"])
check("battery modes are not a step yet (OPT-11): step 5 says they come later",
"later update" in WS["modes_later"]["description"]
and not any("mode" in k for k in BY_STEP[5]))
SPEC = uipage.setup_spec()
check("the spec the page reads is the table's",
SPEC["steps"] == 6 and SPEC["final"] == 6 and SPEC["optional"] == [3]
and SPEC["kettle"]["min_w"] == L.KETTLE_MIN_W
and set(SPEC["kettle"]["sources"]) == set(L.KETTLE)
and SPEC["shadow"] == {"field": "shadow_mode", "on": "on",
"off": "auto"})
_src = L.field(L.KETTLE_SOURCE)
_choices = {c[0] for f in TOP if f["key"] == L.KETTLE_SOURCE
for c in f["choices"]}
_bad = []
for source, (read, outcomes) in L.KETTLE.items():
model = {L.KETTLE_SOURCE: source}
for key, _sign in read:
fld = L.field(key)
if not (fld and fld.wizard == 1 and fld.control == L.ENTITY
and L.visible(fld, model)):
_bad.append(key)
for _say, sets in outcomes.values():
for key in (sets or {}):
fld = L.field(key)
if not (fld and fld.wizard == 1 and L.visible(fld, model)):
_bad.append(key)
check(f"each kettle source is a meter choice, and reads and sets only step-1 "
f"fields shown for it ({_bad})",
set(L.KETTLE) <= _choices and not _bad and _src.wizard == 1)
_shadow = next((f for f in TOP if f["key"] == L.SHADOW[0]), {})
check("the shadow switch writes two of shadow mode's own choices",
{L.SHADOW[1], L.SHADOW[2]} <= {c[0] for c in _shadow.get("choices", [])})
# --------------------------------------------------------------------------- #
print("\n2. the kettle test's sign logic")
# --------------------------------------------------------------------------- #
K = uipage.kettle
check("one grid entity: up keeps the sign, down flips it",
K("off", -10, 1990) == ("ok", {"meter_invert": False}, 2000)
and K("off", 400, -1600) == ("flip", {"meter_invert": True}, -2000))
check("the direction is the change, not the sign: exporting 3 kW, a kettle "
"still moves the reading up",
K("off", -3000, -1000)[0] == "ok" and K("off", 3000, 1000)[0] == "flip")
check(f"a change under {L.KETTLE_MIN_W:.0f} W sets nothing and is not passed",
K("off", 0, 499)[:2] == ("none", None)
and K("off", 0, -499)[:2] == ("none", None)
and K("off", 0, 500)[0] == "ok")
check("a net entity that goes down cannot be fixed by an option, so the step "
"is not passed",
K("ha_signed", 0, 2000) == ("ok", {}, 2000)
and K("ha_signed", 0, -2000)[:2] == ("wrong_signed", None))
check("import and export are read as their difference",
L.KETTLE["ha_dsmr"][0] == (("p1_import_entity", 1),
("p1_export_entity", -1))
and K("ha_dsmr", -3000, -1000)[0] == "ok"
and K("ha_dsmr", 0, -2000)[0] == "wrong_dsmr")
check("a meter the add-on reads itself has no kettle test on the page",
K("homewizard_local", 0, 2000) is None and K("mqtt_p1", 0, 2000) is None)
_js = re.search(r"function kettleOutcome\(src, before, after\) \{(.*?)\n \}",
JS, re.S)
check("the page reaches the same verdict: settings.js kettleOutcome() is "
"uipage.kettle() line for line",
_js and "const d = after - before;" in _js.group(1)
and "Math.abs(d) < M.setup.kettle.min_w" in _js.group(1)
and 'src[d > 0 ? "up" : "down"]' in _js.group(1))
check("...and it applies what the verdict sets, and passes the step only on "
"a verdict that sets something",
"if (o.sets) { Object.assign(V, o.sets); queueValidate(); }" in JS
and "ok: o.sets != null" in JS)
check("a changed meter or entity forgets the result, so the test runs again",
"if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null;" in JS)
check("each verdict has words", all(say in WS["kettle"] for _r, o in
L.KETTLE.values()
for say, _s in o.values())
and {"none", "unread", "skipped", "self"} <= set(WS["kettle"]))
# --------------------------------------------------------------------------- #
print("\n3. which refusals block which step")
# --------------------------------------------------------------------------- #
_m = U.options_to_ui(dict(NEW, meter_source="ha_signed"))
_msgs = [{"field": "p1_net_entity", "card": None, "tier": "bad", "text": "a"},
{"field": "meter_entity", "card": None, "tier": "bad", "text": "b"},
{"field": "inverters.setpoint_entity", "card": 0, "tier": "bad",
"text": "c"},
{"field": "maintenance_charge_w", "card": None, "tier": "bad",
"text": "d"},
{"field": None, "card": None, "tier": "bad", "text": "e"},
{"field": "peak_cap_w", "card": None, "tier": "warn", "text": "f"}]
check("a refusal blocks the step of its field, a hidden or non-wizard field's "
"and an unplaced one the last step, and a warning blocks nothing",
uipage.step_problems(_msgs, _m)
== {"1": 1, "2": 1, "3": 0, "4": 0, "5": 0, "6": 3})
class Store:
"""app/store.py's shape: a dict under .data, and set()."""
def __init__(self, data=None):
self.data = dict(data or {})
def set(self, key, value):
self.data[key] = value
def state(eid, value, unit="W", cls=None):
return {"entity_id": eid, "state": value,
"attributes": {"friendly_name": eid, "unit_of_measurement": unit,
"device_class": cls}}
class Hass:
session = None
token = ""
def __init__(self, states):
self.states_now = states
self.reads = []
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return copy.deepcopy(self.states_now)
async def state(self, entity):
self.reads.append(entity)
return next((copy.deepcopy(s) for s in self.states_now
if s["entity_id"] == entity), None)
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
self.writes = []
self.restarts = 0
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
self.writes.append(copy.deepcopy(options))
self.options = copy.deepcopy(options)
async def restart(self, confirm):
self.restarts += 1
class Controller:
def __init__(self, opts, states, store=None, auto=False, grid=None):
self.o, self.hass, self.inverters = dict(opts), Hass(states), []
self.store, self.auto, self.grid = store, auto, grid
def log_event(self, text, hint=None):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": [],
"plan": {"prices": [{"eur_kwh": 0.2}] * 20
+ [{"eur_kwh": None}] * 4}}
async def handle_action(self, what):
raise AssertionError(f"the wizard reached {what!r}")
async def session(ctl, stored, steps):
sup = Supervisor(stored)
runner = aioweb.AppRunner(web.build_app(ctl, sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
out = await steps(s, root, sup)
finally:
await runner.cleanup()
return out, sup
async def _get(s, url):
async with s.get(url) as r:
return await r.json()
async def _post(s, url, body):
async with s.post(url, json=body) as r:
return r.status, await r.json()
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
state("sensor.inv1_level", "62", "%", "battery"),
state("sensor.inv1_battery", "-800"),
state("sensor.pv", "-1200", "W", "power"),
state("sensor.net", "-3.1", "kW", "power")]
async def _steps(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = copy.deepcopy(m["ui"])
model["inverters"][0]["setpoint_entity"] = ""
return (await _post(s, root + "config/validate", {"model": model}))[1]
_r, _sup = asyncio.run(session(Controller(NEW, STATES), NEW, _steps))
print(f" blank power request entity: steps {_r['steps']}")
check("over HTTP, a blank power request entity blocks step 2 and no other",
_r["steps"]["2"] >= 1
and all(v == 0 for k, v in _r["steps"].items() if k != "2"))
check("the page moves on only when the step's count is 0, the kettle test is "
"done on step 1, and the last step saves only with nothing red",
"if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); "
"firstBad(); return; }" in JS
and "if (n === 1 && !kettleOk())" in JS
and "if (WZ.blocked) { toast(WS.check.blocked.description); return; }"
in JS)
# --------------------------------------------------------------------------- #
print("\n4. first run: a new install opens the wizard, a working site not")
# --------------------------------------------------------------------------- #
S = uipage.setup_needed
check("a new install: never set up, control never started or stopped",
S(NEW, {}, False) == "first_start"
and S(NEW, {"phase": "idle", "sign_tripped": False}, False)
== "first_start")
check("a site that runs control, or ever ran or stopped it, is not sent to "
"the wizard", S(NEW, {}, True) is None
and S(NEW, {"auto": True}, False) is None
and S(NEW, {"auto": False}, False) is None)
check("a site the wizard saved is not sent again",
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False) is None)
check("stored options with no inverter to control always open it, whatever "
"the Store says",
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
== "no_power_request"
and S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": " "}]),
None, True) == "no_power_request")
check("a list roster with a power request entity is set up",
S(dict(DEFAULTS, inverters=[{"name": "a", "setpoint_entity": "number.a"}]),
{"auto": True}, True) is None)
check("without a Store the answer is no (never nag on a guess)",
S(NEW, None, False) is None)
_sites = [(n, dict(DEFAULTS, **json.load(open(os.path.join(
HERE, "scenarios", "ux13", "test23", n))))) for n in sorted(os.listdir(
os.path.join(HERE, "scenarios", "ux13", "test23")))]
_starting = [(n, o) for n, o in _sites if not validate_startup_config(o)]
check(f"the TEST-23 sites that start, and run control, are not sent to the "
f"wizard ({[n for n, _o in _starting]})", len(_starting) >= 3
and all(S(o, {"auto": True, "phase": "idle"}, True) is None
for _n, o in _starting))
check("...and the one that cannot start (no power request entity) is",
[S(o, {"auto": True}, True) for n, o in _sites
if (n, o) not in _starting] == ["no_power_request"])
async def _first(s, root, sup):
out = {"model": await _get(s, root + "config/model?lang=en")}
m = out["model"]
model = copy.deepcopy(m["ui"])
model["peak_cap_w"] = 4000
out["plain"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"]})
out["store_plain"] = dict(_store.data)
out["after_plain"] = await _get(s, root + "config/model?lang=en")
m = out["after_plain"]
model = copy.deepcopy(m["ui"])
out["wizard"] = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
out["after"] = await _get(s, root + "config/model?lang=en")
return out
_store = Store({"phase": "idle"})
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
check("over HTTP, /config/model says a new install needs setup, with the spec",
_r["model"]["setup_needed"] == "first_start"
and _r["model"]["setup"] == json.loads(json.dumps(SPEC)))
check("a save from the Installer view does not count as setup",
_r["plain"][0] == 200 and _r["after_plain"]["setup_needed"]
== "first_start" and uipage.SETUP_DONE not in _r["store_plain"])
check("the wizard's save writes the Store marker, and the wizard stops "
"opening by itself",
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
and _r["after"]["setup_needed"] is None)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
auto=True), NEW, _first))
check("a working site's /config/model says no", _r["model"]["setup_needed"]
is None)
check("the page goes to Setup by itself only when opened with no route and "
"only while still on Home",
"first: !location.hash" in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read()
and 'if (M && M.setup_needed && location.hash === "#home") '
'location.hash = "#setup";' in JS)
# --------------------------------------------------------------------------- #
print("\n5. the last step's live checks")
# --------------------------------------------------------------------------- #
ROWS = {s["entity_id"]: {"entity_id": s["entity_id"], "state": s["state"],
"unit": s["attributes"]["unit_of_measurement"]}
for s in STATES}
SITE = dict(NEW, meter_entity="sensor.grid", soc_entity="sensor.inv1_level",
batt_entity="sensor.inv1_battery", pv_entity="sensor.pv")
def tiers(notes):
return [(n.tier, n.text) for n in notes]
_c = tiers(setup_checks(SITE, entities=ROWS))
for t in _c:
print(f" {t}")
check("the meter reads, as the add-on will see it",
("ok", "The meter reads 402 W taken from the grid now.") in _c)
check("with the sign flipped, the same reading is fed in",
("ok", "The meter reads 402 W fed into the grid now.") in tiers(
setup_checks(dict(SITE, meter_invert=True), entities=ROWS)))
check("the power request entity exists in Home Assistant (a write cannot "
"tell: SAFETY-77)",
("ok", "Home Assistant has number.inv1_power, the power request "
"entity.") in _c)
check("the battery level reads, and the battery power with its direction",
("ok", "The battery level reads 62 %.") in _c
and ("info", "The battery reads 800 W charging now, so check the "
"inverter shows the same.") in _c)
check("a solar entity below zero, not flipped, looks reversed",
("warn", "sensor.pv reads below zero now, so its solar production "
"sign looks reversed.") in _c
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
entities=ROWS)))
_miss = tiers(setup_checks(dict(SITE, setpoint_entity="number.typo"),
entities=ROWS))
check("a power request entity Home Assistant does not have is red, so the "
"wizard does not save",
("bad", "Home Assistant has no entity number.typo, which “Inverter "
"setpoint entity” (setpoint_entity) names.") in _miss)
check("a missing battery level entity is a warning, not red (the add-on "
"holds 0 W and says so)",
[t for t, x in tiers(setup_checks(dict(SITE, soc_entity="sensor.x"),
entities=ROWS))
if "sensor.x" in x] == ["warn"])
_two = dict(DEFAULTS, meter_source="ha_signed", p1_net_entity="sensor.net",
inverters=[{"name": "a", "setpoint_entity": "number.inv1_power",
"soc_entity": "sensor.inv1_level", "max_w": 2000,
"bms_soc_floor": 10},
{"name": "b", "setpoint_entity": "number.b",
"max_w": 2000, "bms_soc_floor": 10}])
_c2 = tiers(setup_checks(_two, entities=ROWS))
check("with two inverters each is named, and a kW meter is shown as the "
"add-on reads it, beside the warning to pick a sensor in W",
("ok", "The meter reads 3 W fed into the grid now.") in _c2
and [t for t, x in _c2 if "reads in kW" in x] == ["warn"]
and ("bad", "Home Assistant has no entity number.b, which “Inverter "
"setpoint entity” for b (inverters.setpoint_entity) names.")
in _c2
and ("ok", "The battery level for a reads 62 %.") in _c2
and ("warn", "There is no battery level entity for b, so the add-on "
"holds 0 W.") in _c2)
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
p1_export_entity="sensor.inv1_level")
check("import and export are read as their difference",
("ok", "The meter reads 340 W taken from the grid now.")
in tiers(setup_checks(dict(_dsmr), entities=dict(
ROWS, **{"sensor.inv1_level": {"state": "62", "unit": "W"}}))))
_hw = dict(SITE, meter_source="homewizard_local", p1_host="192.0.2.5")
check("a meter the add-on reads itself: its live reading when it already "
"runs on it, else a note that Home shows it after the restart",
("ok", "The meter reads 250 W taken from the grid now.") in tiers(
setup_checks(_hw, entities=ROWS, running=_hw, meter_now_w=250))
and [t for t, x in tiers(setup_checks(_hw, entities=ROWS, running=SITE,
meter_now_w=250))
if "meter" in x] == ["info"])
check("an unreadable meter is a warning",
("warn", "sensor.grid reads unavailable now, so the meter has no "
"reading yet.") in tiers(setup_checks(SITE, entities=dict(
ROWS, **{"sensor.grid": {"state": "unavailable",
"unit": "W"}}))))
_down = tiers(setup_checks(SITE, entities=None))
check("Home Assistant down: one warning, nothing claimed ok about an entity",
_down[0] == ("warn", "Home Assistant is not answering, so no entity "
"was checked yet.")
and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x])
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
_dyn = dict(SITE, price_provider="generic_dayahead")
check("prices: known from a contract; for a feed, today's count when it "
"already runs, else after the restart; none when none is set",
("ok", "Your prices come from your contract, so they are always known.")
in tiers(setup_checks(_fixed, entities=ROWS))
and ("ok", "Prices for today are in, for 20 of 24 hours.")
in tiers(setup_checks(_dyn, entities=ROWS, running=_dyn,
prices_known=20))
and ("info", "Prices are fetched after the restart, and Home shows "
"them.") in tiers(setup_checks(_dyn, entities=ROWS,
running=SITE,
prices_known=20))
and ("info", "No prices are set, so the batteries do not charge on "
"price.") in _c)
async def _final(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = copy.deepcopy(m["ui"])
model["inverters"][0]["setpoint_entity"] = "number.typo"
out = {"ok": (await _post(s, root + "config/validate",
{"model": m["ui"], "setup": True}))[1],
"typo": (await _post(s, root + "config/validate",
{"model": model, "setup": True}))[1],
"plain": (await _post(s, root + "config/validate",
{"model": m["ui"]}))[1]}
return out
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
SITE, _final))
check("over HTTP, the last step gets the checks beside the refusals, read "
"from a fresh entity list",
[x["tier"] for x in _r["ok"]["setup"] if "number.inv1_power" in x["text"]]
== ["ok"] and not [m for m in _r["ok"]["messages"] if m["tier"] == "bad"]
and "setup" not in _r["plain"])
check("...and a typed power request entity HA does not have is red there",
["bad"] == [x["tier"] for x in _r["typo"]["setup"]
if "number.typo" in x["text"]])
async def _kettle(s, root, sup):
return (await _get(s, root + "ha/entities?entity=sensor.net"),
await _get(s, root + "ha/entities?entity=sensor.gone"))
(_k, _gone), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
_kettle))
check("the kettle test reads one entity now, in W, and says when it is not "
"there", _k == {"entity_id": "sensor.net", "found": True,
"state": "-3.1", "unit": "kW", "watts": -3100.0}
and _gone["found"] is False and _gone["watts"] is None)
# --------------------------------------------------------------------------- #
print("\n6. the save goes through the P2 path and writes a valid blob")
# --------------------------------------------------------------------------- #
_plan = {"meter": "signed", "roster": "two", "solar": "two",
"prices": "daynight", "region": "flanders", "peak": "forecast",
"shadow": "on"}
async def _wizard(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
model = B.browser(B.apply(m["ui"], _plan))
v = (await _post(s, root + "config/validate", {"model": model,
"setup": True}))[1]
saved = await _post(s, root + "config/save", {
"model": model, "fingerprint": m["fingerprint"], "setup": "done"})
return v, saved
_store = Store()
(_v, _saved), _sup = asyncio.run(session(Controller(NEW, STATES, _store),
NEW, _wizard))
_blob = _sup.writes[-1] if _sup.writes else {}
check("the wizard's model validates clean and saves with 200",
not [m for m in _v["messages"] if m["tier"] == "bad"]
and _saved[0] == 200 and len(_sup.writes) == 1)
check("the blob written starts: the shipped validator accepts it",
not validate_startup_config(_blob))
check("it has what the six steps set: the net meter, two named inverters, "
"two solar rows with one flipped, day and night prices, the Flanders "
"levies, the peak limit, and shadow mode on",
_blob["meter_source"] == "ha_signed"
and [i["name"] for i in _blob["inverters"]] == ["inverter-1",
"inverter-2"]
and _blob["setpoint_entity"] == ""
and _blob["pv_entities"] == ["sensor.pv_a", "sensor.pv_b"]
and _blob["pv_invert_entities"] == ["sensor.pv_b"]
and json.loads(_blob["static_profile"])["periods"][0]["eur_per_kwh"]
== 0.34 and _blob["price_taxes_eur_kwh"]
== U.REGION_PRESETS["flanders"]["taxes_eur_kwh"]
and _blob["peak_cap_w"] == 4200 and _blob["shadow_mode"] == "on")
check("every key config.yaml declares is written, in the stored order",
list(_blob)[:len(NEW)] == list(NEW) and set(_blob) == set(NEW))
check("the wizard saved without a restart of its own: the restart is a "
"second, confirmed request", _sup.restarts == 0
and 'JSON.stringify({ confirm: "restart" })' in JS.split(
"async function finish")[1])
_all = B.blobs()
_new = [(n, r) for n, _b, r in _all if n.startswith("new-")]
_ok = [n for n, _b, r in _all if not r]
print(f" {len(_all)} blobs from {len(B.starts())} starts; "
f"{len(_ok)} accepted")
check(f"every plan from a new install is accepted by the shipped validator "
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
if r])
check("every plan from the TEST-23 sites is accepted, except where the site "
"trades on price with a plan that has no meter or no prices, which the "
"last step then lists",
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
for n, _b, r in _all) and len(_ok) >= 80)
check("every accepted blob keeps every option key (the Supervisor count, 78)",
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
# --------------------------------------------------------------------------- #
print("\n7. a stale save names only what changed elsewhere (P2 finding a)")
# --------------------------------------------------------------------------- #
async def _stale(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000 # the Configuration tab
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12 # the operator's own edit
first = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
again = await _post(s, root + "config/save",
{"model": model, "fingerprint": first[1]["fingerprint"]})
return first, again
(_first_r, _again), _sup = asyncio.run(session(Controller(SITE, STATES), SITE,
_stale))
print(f" changed_fields {_first_r[1]['changed_fields']}, moved "
f"{_first_r[1]['moved']}, changed {_first_r[1]['changed']}")
check("the banner's list is the other surface's key only, not the operator's "
"own edit", _first_r[0] == 409
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
and _first_r[1]["moved"] == ["peak_cap_w"])
check("`changed` (stored against mine, which the classic page shows as "
"theirs -> mine) is unchanged",
_first_r[1]["changed"] == ["peak_cap_w", "price_timeout_s"])
check("saving again with the new token is the deliberate second press: it "
"stores the page as shown (Discard loads theirs instead)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 3500
and _sup.options["price_timeout_s"] == 12)
async def _two_pages(s, root, sup):
"""The classic page and this one open the same blob; the classic one
saves first (the browser run found the token was dropped then)."""
m = await _get(s, root + "config/model?lang=en")
classic = await _get(s, root + "config/model?lang=en")
theirs = dict(classic["options"], peak_cap_w=5000)
await _post(s, root + "config/save", {"options": theirs,
"fingerprint": classic["fingerprint"]})
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12
return await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _two_pages))
check("two pages on one blob: after the other page saved, this one's banner "
"still names only the other page's change",
_r[0] == 409 and _r[1]["changed_fields"] == ["peak_cap_w"])
async def _unknown(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
sup.options["peak_cap_w"] = 5000
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12
return await _post(s, root + "config/save",
{"model": model, "fingerprint": "not-handed-out"})
_r, _sup = asyncio.run(session(Controller(SITE, STATES), SITE, _unknown))
check("a token the add-on did not hand out (it restarted) falls back to "
"every key that differs, as before",
_r[0] == 409 and _r[1]["moved"] is None
and _r[1]["changed_fields"] == ["peak_cap_w", "price_timeout_s"])
# --------------------------------------------------------------------------- #
print("\n8. the wizard's words keep to STYLE")
# --------------------------------------------------------------------------- #
def _texts(node, path=""):
for k, v in node.items():
if isinstance(v, dict):
yield from _texts(v, f"{path}{k}.")
elif isinstance(v, str):
yield f"{path}{k}", v
def _sentences(t):
return len(re.findall(r"[.!?](?:\s|$)", t))
_long = [(p, t) for p, t in _texts(WS)
if len(t.split()) > (25 if p.endswith("description") else 6)
or (p.endswith("description") and _sentences(t) > 1) or ";" in t]
check(f"every wizard string is one sentence of at most 25 words, or a name "
f"of at most 6 ({_long})", not _long and len(list(_texts(WS))) > 50)
_rendered = [x for notes in (
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
setup_checks(SITE, entities=None), setup_checks(_hw, entities=ROWS),
setup_checks(_fixed, entities=ROWS), setup_checks(dict(
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
for x in (n.text for n in notes)]
_over = [t for t in _rendered if len(t.split()) > 25 or _sentences(t) > 1
or ";" in t]
check(f"every live check, rendered with real values, is one sentence of at "
f"most 25 words ({len(_rendered)} rendered, over: {_over})",
len(_rendered) > 20 and not _over)
_dialogs = [WS["dialog"]["description"], WS["dialog_live"]["description"]]
check("both restart dialogs name the 0 W, and the shadow one says it stays",
all("0 W" in d for d in _dialogs) and "shadow mode keeps them at 0 W"
in _dialogs[0])
check("the wizard defaults to shadow mode: the switch starts on",
"shadowOn: true" in JS)
_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
check(f"settings.js, wizard included, names no option key ({_leak})",
not _leak)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+26 -2
View File
@@ -11,6 +11,12 @@ P2: the fixture Supervisor serves config.yaml's defaults with this site's
options, a schema read off config.yaml the way the Supervisor renders it
(`schema_ui()`) and translations/en.yaml, and keeps what the settings view
saves in memory, so the Installer view can be clicked through end to end.
P4: STATE `new` is a new install (config.yaml's defaults with one power
request entity, an empty Store, control never started), so the page opens
the setup wizard by itself. A one-entity read of sensor.grid steps through
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
reversed meter (down).
"""
import asyncio
@@ -74,8 +80,12 @@ class Hass:
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def state(self, e):
s = next((dict(x) for x in STATES if x["entity_id"] == e), None)
if s and e == "sensor.grid":
n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1
s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)])
return s
async def set_number(self, _e, _v):
return True
@@ -87,6 +97,10 @@ class Hass:
return STATES
KETTLE_READS = (-10, 1990, 1990, -10)
KETTLE_COUNT = [0]
def _state(eid, name, state, unit=None, cls=None):
return {"entity_id": eid, "state": state,
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
@@ -240,6 +254,16 @@ def controller(state):
async def main(state, port, host="127.0.0.1"):
if state == "new":
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
c = Controller(dict(stored), Hass(), Store(), Pub(),
stop=asyncio.Event())
c.auto = False
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving new on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
c = controller(state)
stored = dict(defaults(CONFIG), **OPTS)
stored.update(setpoint_entity="", meter_source="ha_signed",
@@ -0,0 +1,254 @@
"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's
own validator run over them.
python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs
docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/w/tools/ux13_wizard_blobs.py --supervisor /blobs
The wizard edits the page model one step at a time and saves it through
uipage.to_options(), like the Installer view. `blobs()` walks each step's
choices (meter source and sign, one to three inverters, solar rows, every
contract and price source, region, peak limit, shadow mode) from a new
install and from the TEST-23 sites, and keeps what the shipped validator
accepts, because the wizard saves nothing it refuses. test_setup.py checks
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
"""
import copy
import glob
import json
import os
import sys
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def browser(value):
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, list):
return [browser(v) for v in value]
if isinstance(value, dict):
return {k: browser(v) for k, v in value.items()}
return value
# --- the wizard's steps, as edits to the page model ------------------------ #
METERS = {
"entity": {"meter_source": "off", "meter_entity": "sensor.grid"},
"entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid",
"meter_invert": True},
"signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"},
"signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
"meter_phases": 3, "p1_phase_net_entities": [
"sensor.l1", "sensor.l2", "sensor.l3"]},
"dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp",
"p1_export_entity": "sensor.exp"},
"homewizard": {"meter_source": "homewizard_local",
"p1_host": "192.0.2.50"},
"mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"},
}
def _card(i, flat=False, **more):
card = {"setpoint_entity": f"number.inv{i}_power",
"soc_entity": f"sensor.inv{i}_level",
"batt_entity": f"sensor.inv{i}_battery", "batt_invert": False,
"max_w": 2500, "bms_soc_floor": 10}
if flat:
card.update(name="", capacity_kwh=None)
else:
card.update(name=f"inverter-{i}", capacity_kwh=5.0)
card.update(more)
return card
ROSTERS = {
"one": ("flat", [_card(1, flat=True)]),
"one_flipped": ("flat", [_card(1, flat=True, batt_invert=True,
max_w=3000)]),
"two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]),
"three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]),
}
SOLAR = {
"none": [],
"one": [{"entity": "sensor.pv", "reversed": False}],
"one_reversed": [{"entity": "sensor.pv", "reversed": True}],
"two": [{"entity": "sensor.pv_a", "reversed": False},
{"entity": "sensor.pv_b", "reversed": True}],
}
PRICES = {
"none": {"ui.contract": "none", "ui.fallback": "none"},
"fixed": {"ui.contract": "fixed", "ui.fallback": "fixed",
"ui.fixed_price": 0.31},
"daynight": {"ui.contract": "daynight", "ui.fallback": "daynight",
"ui.day_price": 0.34, "ui.night_price": 0.25},
"dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead",
"ui.fallback": "none"},
"dynamic_fixed": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "fixed", "ui.fixed_price": 0.3},
"dynamic_daynight": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "daynight", "ui.day_price": 0.3,
"ui.night_price": 0.2},
"formula": {"ui.contract": "dynamic", "price_provider": "custom_formula",
"price_formula": "<the documented example>",
"ui.fallback": "fixed",
"ui.fixed_price": 0.3},
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
"ui.fixed_price": 0.3},
}
REGIONS = {"flanders": {"ui.region": "flanders"},
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""},
"forecast": {"peak_cap_w": 4200,
"peak_forecast_entity": "sensor.peak_forecast"},
"high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}}
SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW)
BASE = {"meter": "entity", "roster": "one", "solar": "none",
"prices": "none", "region": "own", "peak": "low", "shadow": "on"}
def plans():
"""Each step's choices once, with the other steps at BASE, then a few
whole sites that combine them."""
axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR,
"prices": PRICES, "region": REGIONS, "peak": PEAKS,
"shadow": dict.fromkeys(SHADOWS)}
seen = []
for axis, choices in axes.items():
for choice in choices:
plan = dict(BASE, **{axis: choice})
if plan not in seen:
seen.append(plan)
for plan in (
{"meter": "dsmr", "roster": "two", "solar": "two",
"prices": "dynamic_daynight", "region": "flanders",
"peak": "forecast", "shadow": "on"},
{"meter": "signed_3", "roster": "three", "solar": "one_reversed",
"prices": "daynight", "region": "flanders", "peak": "high",
"shadow": "auto"},
{"meter": "homewizard", "roster": "one_flipped", "solar": "one",
"prices": "fixed", "region": "own", "peak": "forecast",
"shadow": "on"},
{"meter": "mqtt", "roster": "two", "solar": "none",
"prices": "formula", "region": "flanders", "peak": "low",
"shadow": "on"}):
seen.append(plan)
return seen
def apply(model, plan):
"""The page model after the wizard's six steps, as the page leaves it."""
from app import uilayout as L, uimodel as U
m = copy.deepcopy(model)
m.update(copy.deepcopy(METERS[plan["meter"]]))
shape, cards = ROSTERS[plan["roster"]]
cards = copy.deepcopy(cards)
if m.get("inverter_shape") == "list":
# A site stored as a list stays one: one card keeps a name.
for i, card in enumerate(cards, 1):
card.update(name=card["name"] or f"inverter-{i}",
capacity_kwh=card["capacity_kwh"] or 5.0)
m["inverters"] = cards
m[U.COUNT] = len(cards)
if shape == "list":
m.update(U.GROWN)
m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]])
m.update(copy.deepcopy(PRICES[plan["prices"]]))
if plan["prices"] == "formula":
from app.custom_formula import _LEGS
m["price_formula"] = _LEGS["price_formula"].example
m.update(copy.deepcopy(REGIONS[plan["region"]]))
if plan["region"] != "own":
m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][
"taxes_eur_kwh"]
m.update(PEAKS[plan["peak"]])
field, on, off = L.SHADOW
m[field] = on if plan["shadow"] == "on" else (
m.get(field) if m.get(field) != on else off)
return m
def starts():
"""(name, stored options) the wizard opens on: a new install (config.yaml's
defaults and one power request entity, typed in the Configuration tab so
the add-on starts) and the TEST-23 sites."""
sys.path.insert(0, os.path.join(HERE, "tools"))
import ux13_demo as D
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
out = [("new", new)]
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
"*.json")))[:3]:
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
**json.load(open(f)))))
return out
def blobs():
"""[(name, blob, refusals)] for every plan from every start."""
sys.path.insert(0, HERE)
from app import uimodel as U, uipage
from app.validate import validate_startup_config
out = []
for start, stored in starts():
model = json.loads(json.dumps(U.options_to_ui(stored)))
for plan in plans():
name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items())
posted = browser(json.loads(json.dumps(apply(model, plan))))
blob = uipage.to_options(posted, stored)
out.append((name, blob, validate_startup_config(blob)))
return out
# --- run inside the Supervisor image --------------------------------------- #
def supervisor(folder):
sys.path.insert(0, "/usr/src/supervisor")
import voluptuous as vol
from pathlib import Path
from supervisor.apps.options import AppOptions
from supervisor.apps.validate import SCHEMA_APP_CONFIG
from supervisor.utils.yaml import read_yaml_file
cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml"))
check = vol.Schema(vol.All(dict, AppOptions(
None, cfg["schema"], cfg["name"], "local_goodwe_controller")))
bad = 0
files = sorted(glob.glob(os.path.join(folder, "*.json")))
for f in files:
blob = json.load(open(f))
try:
got = check(copy.deepcopy(blob))
dropped = sorted(set(blob) - set(got))
ok = not dropped
why = f"dropped {dropped}" if dropped else f"{len(got)} keys"
except vol.Invalid as err:
ok, why = False, str(err)
bad += not ok
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}")
print(f"{len(files) - bad} of {len(files)} accepted by AppOptions "
f"({cfg['version']})")
return 1 if bad or not files else 0
if __name__ == "__main__":
if sys.argv[1:2] == ["--supervisor"]:
sys.exit(supervisor(sys.argv[2]))
if sys.argv[1:2] == ["--dump"]:
os.makedirs(sys.argv[2], exist_ok=True)
n = 0
for name, blob, refused in blobs():
if refused:
print("refused, not written:", name, refused[0])
continue
with open(os.path.join(sys.argv[2], name + ".json"), "w") as f:
json.dump(blob, f, indent=1)
n += 1
print(f"{n} blobs written to {sys.argv[2]}")
sys.exit(0)
print(__doc__)