Merge pull request 'UX-13 P4: the setup wizard' (#146) from UX-13-p4-setup into release/1.0
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
This commit was merged in pull request #146.
This commit is contained in:
@@ -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,101 @@
|
||||
# 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`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||
`auto` is the key Start/Stop writes. The startup gate exits 78 before the
|
||||
controller writes the Store, so a new install's first good boot is fresh.
|
||||
|
||||
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
||||
R1). So a Store read from a file is an old site, and it is never sent to the
|
||||
wizard. A new install that restarts before the page is opened is not sent
|
||||
either, and its Setup tab still works. A site that runs control, or ever
|
||||
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
|
||||
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||
not.
|
||||
|
||||
**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, because a cloud can fake a fall;
|
||||
the last step then warns, and after a fall it says the reading went down.
|
||||
|
||||
The battery and solar signs cannot be forced from the page, which never
|
||||
commands an inverter. The last step reports each battery's reading with its
|
||||
direction ("reads 800 W charging now, so check the inverter shows the same")
|
||||
and warns when a solar entity reads below zero without its "reads negative"
|
||||
switch. It also warns when meter + solar + battery, all known, add up to a
|
||||
house using less than -300 W: a battery sensor reversed while giving power.
|
||||
An idle or charging battery cannot be judged that way.
|
||||
|
||||
## 4. The last step
|
||||
|
||||
`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. So is Home Assistant not answering, since
|
||||
the list is then unknown. The wizard does not save while anything is
|
||||
bad. It then saves through P2's path (`setup: "done"`) and asks for the
|
||||
restart as the second, confirmed request, with a dialog that names the 0 W.
|
||||
The shadow switch is on by default and writes `shadow_mode: on`, which holds
|
||||
every inverter at 0 W after a restart even if control is started. Off keeps the
|
||||
stored value, or `auto` if that was `on`. The switch goes only on the model the
|
||||
wizard posts, never into the page model the Installer view also saves.
|
||||
|
||||
## 5. The stale save lists only what moved (P2 test, finding a)
|
||||
|
||||
`/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.
|
||||
@@ -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 on a new install's first start, until Setup finishes or
|
||||
control is started, and whenever the stored settings name no inverter to
|
||||
control. A site that already ran before is never sent there. The add-on does
|
||||
not start with no power request entity at all, so on a brand-new install fill
|
||||
in "Inverter setpoint entity" on the Configuration tab first.
|
||||
|
||||
*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
|
||||
|
||||
@@ -13,6 +13,7 @@ CACHE_S = 30.0
|
||||
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
||||
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
||||
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
|
||||
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||
|
||||
|
||||
@@ -28,6 +29,18 @@ def row(state: dict) -> dict | None:
|
||||
"device_class": attrs.get("device_class")}
|
||||
|
||||
|
||||
def watts(r: dict | None):
|
||||
"""A power entity's reading in W, or None when it is not a number in a
|
||||
power unit (the setup wizard's kettle test)."""
|
||||
if not r or r.get("unit") not in _TO_W:
|
||||
return None
|
||||
try:
|
||||
value = float(r.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value * _TO_W[r["unit"]] if value == value else None
|
||||
|
||||
|
||||
def matches(r: dict, kind: str) -> bool:
|
||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||
@@ -54,13 +67,16 @@ class EntityCache:
|
||||
self.tried_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read."""
|
||||
async def get(self, max_age: float = CACHE_S
|
||||
) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read.
|
||||
`max_age` is how old a try may be before HA is asked again: the setup
|
||||
wizard's last step asks for a list at most a few seconds old."""
|
||||
async with self._lock: # one HA read however many pickers ask
|
||||
now = self.clock()
|
||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||
# not queue one 10 s read each behind this lock.
|
||||
if self.tried_at is None or now - self.tried_at >= CACHE_S:
|
||||
if self.tried_at is None or now - self.tried_at >= max_age:
|
||||
self.tried_at = now
|
||||
states = await self.hass.states()
|
||||
if states is not None:
|
||||
@@ -71,6 +87,14 @@ class EntityCache:
|
||||
return None, None
|
||||
return self.rows, now - self.read_at
|
||||
|
||||
async def reading(self, entity_id: str) -> dict:
|
||||
"""One entity read now, not from the list: the kettle test compares
|
||||
two readings seconds apart, which a 30 s list cannot. A read only."""
|
||||
r = row(await self.hass.state(entity_id)) if entity_id else None
|
||||
return {"entity_id": entity_id, "found": r is not None,
|
||||
"state": r and r["state"], "unit": r and r["unit"],
|
||||
"watts": watts(r)}
|
||||
|
||||
def peek(self) -> dict | None:
|
||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
||||
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
||||
|
||||
@@ -131,11 +131,16 @@ class SaveRefused(Exception):
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None,
|
||||
moved=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
||||
# against stored-now - when the caller knows what it opened. None
|
||||
# when it does not. `changed` also holds the operator's own edits.
|
||||
self.moved = None if moved is None else list(moved)
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
@@ -359,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
hass=None, base=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
@@ -382,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
|
||||
`base`, when given, is the blob the page opened (the one `expect` is the
|
||||
fingerprint of). A stale refusal then also names the keys that moved
|
||||
underneath it, so the page does not list the operator's own edits among
|
||||
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
@@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
moved = (changed_keys(base, stored)
|
||||
if isinstance(base, dict) and fingerprint(base) == expect
|
||||
else None)
|
||||
keys = changed if moved is None else moved
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
", ".join(keys) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||
moved=moved)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
|
||||
@@ -216,3 +216,11 @@ input:disabled{opacity:.6}
|
||||
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
||||
@media (max-width:640px){#savebar{bottom:62px}}
|
||||
#s-set{padding-bottom:80px}
|
||||
/* UX-13 P4: the setup wizard */
|
||||
.steps{display:flex;gap:4px;margin:4px 0 12px}
|
||||
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
|
||||
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
|
||||
.msg.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
|
||||
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
|
||||
.kettle .row{margin-top:8px}
|
||||
|
||||
@@ -332,7 +332,9 @@
|
||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||
|
||||
// Shared with settings.js, which loads after this file.
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) };
|
||||
// `first`: the page opened with no route, so settings.js may open the setup
|
||||
// wizard on a new install (UX-13 P4).
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
|
||||
@@ -117,11 +117,18 @@
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
|
||||
<div class="card">
|
||||
<h2 data-t="setup.name"></h2>
|
||||
<p class="small muted" data-t="setup.description"></p>
|
||||
<a class="btn" href="config" data-t="settings.open"></a>
|
||||
<div class="card" id="wz">
|
||||
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
|
||||
<div class="steps" id="wz-steps" aria-hidden="true"></div>
|
||||
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
|
||||
<div id="wz-banner" aria-live="polite"></div>
|
||||
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
|
||||
<div class="row spread" id="wz-nav" style="margin-top:14px">
|
||||
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
|
||||
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tiny muted" data-s="setup.description"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
ranges and choices all arrive as data. It posts the page model back; the
|
||||
add-on turns it into options and runs its own start checks, and nothing
|
||||
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||||
data is set as text, never as HTML. */
|
||||
data is set as text, never as HTML.
|
||||
P4: the Setup tab's wizard is the same page model, drawn one step at a
|
||||
time from each field's `wizard` step, and saved through the same routes. */
|
||||
(function () {
|
||||
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
||||
const W = ROOT.settings;
|
||||
@@ -28,7 +30,8 @@
|
||||
let DEVICE = "";
|
||||
let LIST = null; // the inverter list field
|
||||
let FILLED = {}; // field key -> the preset field that fills it
|
||||
let active = false;
|
||||
let view = null; // "settings" or "setup": the tab drawing V
|
||||
let STEPS = {}; // wizard step -> refusals that block it
|
||||
let uid = 0;
|
||||
const ROWS = [];
|
||||
const OPEN = new Set();
|
||||
@@ -60,7 +63,7 @@
|
||||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||||
}
|
||||
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||||
if (redraw || f.fills) render(); else paint();
|
||||
if (redraw || f.fills) redrawView(); else paint();
|
||||
queueValidate();
|
||||
}
|
||||
function findField(key) {
|
||||
@@ -290,6 +293,7 @@
|
||||
|
||||
/* ---------------- rows, cards and groups ---------------- */
|
||||
function keyText(f, card) {
|
||||
if (view === "setup") return ""; // the wizard shows no keys
|
||||
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||||
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||
}
|
||||
@@ -324,7 +328,7 @@
|
||||
} else {
|
||||
cards.forEach((c, i) => {
|
||||
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
||||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||||
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
|
||||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||||
@@ -393,7 +397,7 @@
|
||||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||
}
|
||||
function paint() {
|
||||
const q = $("#set-search").value.trim().toLowerCase();
|
||||
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
const placed = new Set();
|
||||
for (const r of ROWS) {
|
||||
r.visible = visible(r.f);
|
||||
@@ -451,7 +455,7 @@
|
||||
}
|
||||
function savebar() {
|
||||
const n = V ? dirtyCount() : 0;
|
||||
$("#savebar").hidden = !(active && n);
|
||||
$("#savebar").hidden = !(view === "settings" && n);
|
||||
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||
}
|
||||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||
@@ -459,17 +463,21 @@
|
||||
/* ---------------- talking to the add-on ---------------- */
|
||||
let vtimer = null, vseq = 0;
|
||||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||||
async function validate() {
|
||||
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
|
||||
// or null when it failed or a newer check overtook it.
|
||||
async function validate(extra) {
|
||||
const seq = ++vseq;
|
||||
try {
|
||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: V }) });
|
||||
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
|
||||
const b = await r.json();
|
||||
if (seq !== vseq) return;
|
||||
if (seq !== vseq) return null;
|
||||
MSG = b.messages || [];
|
||||
DEVICE = b.device_note || "";
|
||||
STEPS = b.steps || {};
|
||||
paint();
|
||||
} catch (e) { /* the next change tries again */ }
|
||||
return b;
|
||||
} catch (e) { return null; /* the next change tries again */ }
|
||||
}
|
||||
function banner(level, title, sub, button, onClick) {
|
||||
const host = $("#set-banner");
|
||||
@@ -513,7 +521,7 @@
|
||||
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
||||
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||||
else banner();
|
||||
render();
|
||||
if (view === "setup") drawStep(); else render();
|
||||
validate();
|
||||
}
|
||||
function firstBad() {
|
||||
@@ -524,6 +532,20 @@
|
||||
const input = r.el.querySelector("input,select,button");
|
||||
if (input) input.focus({ preventScroll: true });
|
||||
}
|
||||
// The one save path, for the Installer view and the wizard. After a stale
|
||||
// refusal the page holds the new token, so a second press saves.
|
||||
async function postSave(extra) {
|
||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||||
return { r, b };
|
||||
}
|
||||
// The settings another surface changed (not this page's own edits).
|
||||
function staleText(b) {
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
|
||||
}
|
||||
async function save() {
|
||||
const btn = $("#sb-save");
|
||||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||
@@ -531,14 +553,10 @@
|
||||
clearTimeout(vtimer);
|
||||
await validate();
|
||||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: V, fingerprint: FP }) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
const { r, b } = await postSave();
|
||||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||
if (r.status === 409 && b.kind === "stale") {
|
||||
FP = b.fingerprint || FP;
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
|
||||
banner("bad", W.stale.name, staleText(b));
|
||||
return;
|
||||
}
|
||||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||||
@@ -552,10 +570,239 @@
|
||||
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, wrong: !k.ok && !!k.wrong };
|
||||
else {
|
||||
const o = kettleOutcome(src, k.before, w);
|
||||
// `wrong`: the reading went the way no option can fix. A skip after
|
||||
// that keeps saying so on the last step (review R2).
|
||||
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
|
||||
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
|
||||
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||||
}
|
||||
drawStep();
|
||||
});
|
||||
const row = h("div", { class: "row" }, read);
|
||||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
||||
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
|
||||
} }, WS.kettle.skip));
|
||||
box.append(row);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Step 6: the shadow switch, the checks, and Save and restart.
|
||||
// The shadow switch goes only on the model the wizard posts, never into V,
|
||||
// which the Installer view saves too (review R3).
|
||||
function shadowValue() {
|
||||
const s = M.setup.shadow;
|
||||
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
|
||||
}
|
||||
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
|
||||
function shadowRow() {
|
||||
const lid = "wzs";
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
||||
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; 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, model: posted() });
|
||||
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", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
|
||||
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||||
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 = shadowValue() === M.setup.shadow.on;
|
||||
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
|
||||
wzBanner();
|
||||
let res;
|
||||
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
|
||||
const { r, b: body } = res;
|
||||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||||
if (r.status === 422) {
|
||||
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));
|
||||
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"; });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -585,6 +585,9 @@ class Store:
|
||||
def __init__(self, path: str = "/data/state.json"):
|
||||
self.path = path
|
||||
self.data = dict(DEFAULTS)
|
||||
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
||||
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
||||
self.fresh = False
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -598,6 +601,7 @@ class Store:
|
||||
self.data.get("phase"), self.data.get("last_completed"),
|
||||
self.data.get("sign_tripped"))
|
||||
except FileNotFoundError:
|
||||
self.fresh = True
|
||||
_LOG.info("no saved state, starting fresh")
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
# A corrupt state file must not stop the controller: losing the
|
||||
|
||||
@@ -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,60 @@
|
||||
"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."},
|
||||
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
|
||||
"tested": {"description": "The meter direction was tested with a kettle."},
|
||||
"needed": {"description": "Do the kettle test first, or skip it."}
|
||||
},
|
||||
"check": {
|
||||
"name": "What the add-on checked",
|
||||
"running": {"description": "Checking with the add-on's own start checks."},
|
||||
"pass": {"description": "The add-on's start checks pass."},
|
||||
"failed": {"description": "The check did not run, so try again."},
|
||||
"again": "Check again",
|
||||
"blocked": {"description": "Fix the items marked in red, then check again."}
|
||||
},
|
||||
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
||||
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
||||
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
||||
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
|
||||
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
|
||||
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
|
||||
"home": "Open Home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,34 @@ GROUPS = (
|
||||
), support_only=True),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
|
||||
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
|
||||
# --------------------------------------------------------------------------- #
|
||||
WIZARD_STEPS = 6
|
||||
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
|
||||
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
|
||||
|
||||
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
|
||||
# read before and after a kettle is switched on, each with the sign it adds to
|
||||
# the grid reading, and what each direction means as (outcome, fields it
|
||||
# sets). Sets None: the options cannot fix it, so the step is not passed. A
|
||||
# source not listed is read by the add-on itself, so the page cannot test it.
|
||||
KETTLE_SOURCE = "meter_source"
|
||||
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
|
||||
KETTLE = {
|
||||
"off": ((("meter_entity", 1),),
|
||||
{"up": ("ok", {"meter_invert": False}),
|
||||
"down": ("flip", {"meter_invert": True})}),
|
||||
"ha_signed": ((("p1_net_entity", 1),),
|
||||
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
|
||||
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
|
||||
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
|
||||
}
|
||||
# Step 6 starts the site in shadow mode unless the installer switches it off:
|
||||
# (field, value when on, value when off).
|
||||
SHADOW = ("shadow_mode", "on", "auto")
|
||||
|
||||
# Option keys the page never shows. uimodel.py writes each from page fields.
|
||||
DERIVED = {
|
||||
"setpoint_entity": "inverters", # one inverter, stored flat
|
||||
|
||||
@@ -17,6 +17,7 @@ import re
|
||||
from . import uilayout as L
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
|
||||
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||
"en.json")
|
||||
@@ -101,6 +102,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
"help": text, "unit": fld.unit, "scale": fld.scale,
|
||||
"step": fld.step, "kind": fld.kind,
|
||||
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
||||
"wizard": fld.wizard,
|
||||
"type": value_type, "optional": not node.get("required", False),
|
||||
# A choice mirrored into a field also shows (and finds) its keys.
|
||||
"writes": sorted(k for k, how in L.DERIVED.items()
|
||||
@@ -278,3 +280,78 @@ def fields_of(keys, model) -> list:
|
||||
"""The page fields that show these option keys (a stale save's list)."""
|
||||
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
|
||||
if f))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Store keys (app/store.py), not options. SETUP_DONE is written when the
|
||||
# wizard saves. STARTED is the one control's Start and Stop write.
|
||||
SETUP_DONE = "setup_done"
|
||||
STARTED = "auto"
|
||||
|
||||
|
||||
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
|
||||
"""Why the page opens the wizard by itself, or None.
|
||||
|
||||
"no_power_request": the stored options name no inverter to control, so
|
||||
the add-on cannot start on them. "first_start": a new install. This boot
|
||||
found no Store file (`fresh`), the wizard never saved and control was never
|
||||
started or stopped. `fresh` is what tells a new install from an old site
|
||||
that was never started, e.g. one left in shadow for weeks (TEST-23).
|
||||
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
||||
whether control is started now."""
|
||||
if not power_request_entities(stored or {}):
|
||||
return "no_power_request"
|
||||
if store_data is None or not fresh:
|
||||
return None
|
||||
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
||||
return None
|
||||
return "first_start"
|
||||
|
||||
|
||||
def setup_spec() -> dict:
|
||||
"""What the wizard needs beyond each field's step: read off the table."""
|
||||
kettle = {}
|
||||
for source, (read, outcomes) in L.KETTLE.items():
|
||||
kettle[source] = {"read": [[f, sign] for f, sign in read]}
|
||||
for way, (say, sets) in outcomes.items():
|
||||
kettle[source][way] = {"say": say, "sets": sets}
|
||||
field, on, off = L.SHADOW
|
||||
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
|
||||
"optional": list(L.OPTIONAL_STEPS),
|
||||
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
|
||||
"sources": kettle},
|
||||
"shadow": {"field": field, "on": on, "off": off}}
|
||||
|
||||
|
||||
def kettle(source, before_w, after_w):
|
||||
"""The kettle test's verdict, as the page reaches it (settings.js
|
||||
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
|
||||
for a source the page cannot read."""
|
||||
spec = L.KETTLE.get(source)
|
||||
if spec is None:
|
||||
return None
|
||||
change = float(after_w) - float(before_w)
|
||||
if abs(change) < L.KETTLE_MIN_W:
|
||||
return "none", None, change
|
||||
say, sets = spec[1]["up" if change > 0 else "down"]
|
||||
return say, (dict(sets) if sets is not None else None), change
|
||||
|
||||
|
||||
def step_of(field_key, model) -> int:
|
||||
"""The wizard step a sentence on this page field blocks: the field's own
|
||||
step while it is shown, else the last step, which lists everything."""
|
||||
fld = L.field(field_key) if field_key else None
|
||||
if fld is None or not fld.wizard or not L.visible(fld, model):
|
||||
return L.FINAL_STEP
|
||||
return fld.wizard
|
||||
|
||||
|
||||
def step_problems(messages, model) -> dict:
|
||||
"""{"1": n, ...}: how many refusals block each wizard step."""
|
||||
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
|
||||
for m in messages:
|
||||
if m.get("tier") == "bad":
|
||||
out[str(step_of(m.get("field"), model))] += 1
|
||||
return out
|
||||
|
||||
@@ -374,7 +374,8 @@ class Note(NamedTuple):
|
||||
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
||||
add-on starts. `keys` are the options it is about, so the page can show it
|
||||
beside them."""
|
||||
tier: str # "warn" or "info"
|
||||
tier: str # "warn" or "info"; the wizard's checks
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
|
||||
@@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
_entity_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
@@ -1769,6 +1759,256 @@ 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
|
||||
# Meter + solar + battery is the house's use, never below zero. Below minus
|
||||
# this, read seconds apart, a direction is reversed (a battery giving power).
|
||||
BALANCE_W = 300.0
|
||||
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
||||
# MQTT) is only known live when the running options read the same one.
|
||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
||||
|
||||
|
||||
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:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.")
|
||||
else:
|
||||
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
||||
meter = _meter_check(notes, opts, entities, running, meter_now_w)
|
||||
batts = [_card_checks(notes, who, entry, entities)
|
||||
for who, entry in _cards(opts)]
|
||||
solar = []
|
||||
if entities is not None:
|
||||
flipped = pv.inverted(opts)
|
||||
for entity in pv.entities(opts):
|
||||
w, _unit = _reading(entities, entity, convert=True)
|
||||
if entity not in entities or w is None:
|
||||
solar.append(None)
|
||||
continue
|
||||
if w < -IDLE_W and entity not in flipped:
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
known = [meter] + batts + solar
|
||||
if solar and batts and all(w is not None for w in known) \
|
||||
and sum(known) < -BALANCE_W:
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
|
||||
def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
"""The meter line. Returns the grid reading in W (positive: taken from
|
||||
the grid) as the add-on will read it, or None when it is not known."""
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
if source == "off":
|
||||
parts = [(opts.get("meter_entity"), 1.0)]
|
||||
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))
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
total = 0.0
|
||||
for entity, sign in parts:
|
||||
entity = str(entity or "").strip()
|
||||
if not entity or entity not in entities:
|
||||
return None # a refusal or a missing-entity note says so
|
||||
w, text = _reading(entities, entity)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
total = -total
|
||||
_meter_line(notes, total)
|
||||
return total
|
||||
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
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):
|
||||
"""The checks for one inverter's card. Returns its battery power in W,
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
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 pick one in step 2 or the add-on "
|
||||
"holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_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 None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"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.")
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def _price_check(notes, opts, running, prices_known) -> None:
|
||||
provider = str(opts.get("price_provider") or "off").strip()
|
||||
profile = str(opts.get("static_profile") or "").strip()
|
||||
if provider in ("", "off"):
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -28,19 +28,23 @@ do to the inverter through this panel is exactly what `/action` offered before
|
||||
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
||||
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
|
||||
of Home Assistant's entity list (app/haentities.py) and writes nothing.
|
||||
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
|
||||
not options is a Store key, `setup_done`, written when the wizard's save
|
||||
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, selfconfig, uipage
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .validate import config_notes
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
@@ -103,6 +107,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
controller.hass.token)
|
||||
entities = EntityCache(controller.hass)
|
||||
index_page = page()
|
||||
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
|
||||
# save can name only the keys that moved underneath the page (finding a).
|
||||
handed: dict = {}
|
||||
|
||||
def hand_out(options):
|
||||
fp = selfconfig.fingerprint(options)
|
||||
handed.pop(fp, None)
|
||||
handed[fp] = options
|
||||
while len(handed) > 16:
|
||||
handed.pop(next(iter(handed)))
|
||||
return fp
|
||||
|
||||
def store_data():
|
||||
store = getattr(controller, "store", None)
|
||||
data = getattr(store, "data", None)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=index_page, content_type="text/html")
|
||||
@@ -165,7 +185,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"fingerprint": hand_out(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
@@ -176,6 +196,12 @@ 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)),
|
||||
fresh=getattr(getattr(controller, "store", None), "fresh",
|
||||
False) is True),
|
||||
})
|
||||
return web.json_response(model)
|
||||
|
||||
@@ -213,8 +239,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 +294,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 +322,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 +331,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 +368,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
"""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
|
||||
import tempfile
|
||||
|
||||
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, fresh=False):
|
||||
self.data = dict(data or {})
|
||||
self.fresh = fresh
|
||||
|
||||
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: no Store file at this boot, never set up, control "
|
||||
"never started or stopped",
|
||||
S(NEW, {}, False, fresh=True) == "first_start"
|
||||
and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True)
|
||||
== "first_start")
|
||||
check("a site that runs control, or ever ran or stopped it, is not sent to "
|
||||
"the wizard", S(NEW, {}, True, fresh=True) is None
|
||||
and S(NEW, {"auto": True}, False, fresh=True) is None
|
||||
and S(NEW, {"auto": False}, False, fresh=True) is None)
|
||||
check("a site the wizard saved is not sent again",
|
||||
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False,
|
||||
fresh=True) is None)
|
||||
# Review R1: the Stores the two TEST-23 add-ons really have (2026-09-28): no
|
||||
# `auto` (Start/Stop never pressed, auto_start off), no setup_done, stopped,
|
||||
# one of them in shadow for weeks. A Store read from a file is an old site.
|
||||
_T23_ADDON = dict.fromkeys((
|
||||
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
|
||||
"maint_schema_version", "phase", "phase_started", "schema_version",
|
||||
"sign_reason", "sign_tripped", "site_sign_reason", "site_sign_tripped",
|
||||
"unit/inverter-a/last_completed", "unit/inverter-a/last_start_attempt",
|
||||
"unit/inverter-a/phase", "unit/inverter-a/phase_started",
|
||||
"unit/inverter-a/sign_reason", "unit/inverter-a/sign_tripped"))
|
||||
_T23_ARB = dict.fromkeys((
|
||||
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
|
||||
"phase", "phase_started", "schema_version", "sign_reason", "sign_tripped",
|
||||
"site_sign_reason", "site_sign_tripped", "unit/inverter-a/sign_reason",
|
||||
"unit/inverter-a/sign_tripped"))
|
||||
check("an old site never started (the real TEST-23 Stores, one in shadow) is "
|
||||
"not sent to the wizard: its Store came from a file (review R1)",
|
||||
S(NEW, _T23_ADDON, False) is None and S(NEW, _T23_ARB, False) is None
|
||||
and S(dict(NEW, shadow_mode="on"), _T23_ARB, False) is None
|
||||
and S(NEW, {}, False) is None)
|
||||
check("stored options with no inverter to control always open it, whatever "
|
||||
"the Store says",
|
||||
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
|
||||
== "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"}, fresh=True)
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
|
||||
check("over HTTP, /config/model says a new install needs setup, with the spec",
|
||||
_r["model"]["setup_needed"] == "first_start"
|
||||
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(_T23_ARB)), NEW,
|
||||
_first))
|
||||
check("over HTTP, an old site never started (a Store read from a file) is not "
|
||||
"sent to the wizard (review R1)", _r["model"]["setup_needed"] is None)
|
||||
with tempfile.TemporaryDirectory() as _d:
|
||||
from app.store import Store as _RealStore
|
||||
_path = os.path.join(_d, "state.json")
|
||||
_new = _RealStore(_path)
|
||||
_new.set("phase", "idle")
|
||||
_old = _RealStore(_path)
|
||||
with open(_path, "w") as _fh:
|
||||
_fh.write("{not json")
|
||||
_bad = _RealStore(_path)
|
||||
check("app/store.py: fresh only when this boot found no state file (a new "
|
||||
"/data), not after a save and not for an unreadable file",
|
||||
_new.fresh is True and _old.fresh is False and _bad.fresh is False)
|
||||
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
|
||||
auto=True), NEW, _first))
|
||||
check("a working site's /config/model says no", _r["model"]["setup_needed"]
|
||||
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 turn on its “Reads "
|
||||
"negative” switch in the Solar step.") in _c
|
||||
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
|
||||
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
|
||||
entities=ROWS)))
|
||||
_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 pick one in "
|
||||
"step 2 or the add-on holds 0 W.") in _c2)
|
||||
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
|
||||
p1_export_entity="sensor.inv1_level")
|
||||
check("import and export are read as their difference",
|
||||
("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 check it in Home "
|
||||
"Assistant before you start.") in tiers(setup_checks(SITE, entities=dict(
|
||||
ROWS, **{"sensor.grid": {"state": "unavailable",
|
||||
"unit": "W"}}))))
|
||||
_down = tiers(setup_checks(SITE, entities=None))
|
||||
check("Home Assistant down: red, because the power request entity cannot be "
|
||||
"checked (review R4), and nothing claimed ok about an entity",
|
||||
_down[0] == ("bad", "Home Assistant is not answering, so press Check "
|
||||
"again once it is back.")
|
||||
and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x])
|
||||
# Review R5: meter + solar + battery is the house's use, never below zero. A
|
||||
# battery giving 1 500 W whose sensor reads it the wrong way (-1 500 W).
|
||||
_rev = dict(ROWS, **{"sensor.pv": {"state": "0", "unit": "W"},
|
||||
"sensor.inv1_battery": {"state": "-1500", "unit": "W"}})
|
||||
_bal = ("warn", "The readings add up to -1 098 W of house use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
check("a battery sensor reading the wrong way while giving power is flagged "
|
||||
"(review R5); not once its switch is on, not without solar, not with "
|
||||
"the battery reading unknown, and not on the normal site",
|
||||
_bal in tiers(setup_checks(SITE, entities=_rev))
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_invert=True),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, pv_entity=""),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_entity=""),
|
||||
entities=_rev))
|
||||
if "add up" in x]
|
||||
and not [x for x in (t[1] for t in _c) if "add up" in x])
|
||||
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
|
||||
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
|
||||
_dyn = dict(SITE, price_provider="generic_dayahead")
|
||||
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(SITE, entities=_rev),
|
||||
setup_checks(dict(
|
||||
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
|
||||
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
|
||||
for x in (n.text for n in notes)]
|
||||
_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)
|
||||
check("the shadow switch never writes into V, which the Installer view saves "
|
||||
"too: it goes only on the model the wizard posts (review R3)",
|
||||
"V[M.setup.shadow.field]" not in JS and "V[s.field]" not in JS
|
||||
and JS.count("model: posted()") == 2)
|
||||
check("a skip after a fall keeps saying the reading went down (review R2)",
|
||||
"wrong: !!k.wrong }; drawStep();" in JS
|
||||
and "WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped" in JS
|
||||
and 'const wrong = o.say === "none" ? !!k.wrong : o.sets == null;' in JS)
|
||||
_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
|
||||
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
|
||||
check(f"settings.js, wizard included, names no option key ({_leak})",
|
||||
not _leak)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -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 made at this boot, control never started), so the page opens
|
||||
the setup wizard by itself. A one-entity read of sensor.grid steps through
|
||||
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
|
||||
reversed meter (down).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -39,6 +45,8 @@ PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
|
||||
|
||||
|
||||
class Store(dict):
|
||||
fresh = False # app/store.py: no state file at this boot
|
||||
|
||||
def set(self, k, v):
|
||||
self[k] = v
|
||||
|
||||
@@ -74,8 +82,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 +99,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 +256,18 @@ def controller(state):
|
||||
|
||||
|
||||
async def main(state, port, host="127.0.0.1"):
|
||||
if state == "new":
|
||||
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
|
||||
store = Store()
|
||||
store.fresh = True # a new /data: the wizard opens by itself
|
||||
c = Controller(dict(stored), Hass(), store, Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = False
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
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__)
|
||||
Reference in New Issue
Block a user