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
|
to the existing `/config/validate` and `/config/save`, which turn it into
|
||||||
options with `ui_to_options()` before the shipped validator runs. No route
|
options with `ui_to_options()` before the shipped validator runs. No route
|
||||||
was added.
|
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
|
## 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
|
page and add `status` to its address. The action-log tables in this document
|
||||||
refer to it.*
|
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
|
## Status page
|
||||||
|
|
||||||
The add-on page shows a banner at the top and a **Commissioning** list below
|
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."
|
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."
|
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
_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")
|
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +29,18 @@ def row(state: dict) -> dict | None:
|
|||||||
"device_class": attrs.get("device_class")}
|
"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:
|
def matches(r: dict, kind: str) -> bool:
|
||||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||||
@@ -54,13 +67,16 @@ class EntityCache:
|
|||||||
self.tried_at: float | None = None
|
self.tried_at: float | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def get(self) -> tuple[dict | None, float | None]:
|
async def get(self, max_age: float = CACHE_S
|
||||||
"""(rows by entity id, age in s), or (None, None) if never read."""
|
) -> 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
|
async with self._lock: # one HA read however many pickers ask
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||||
# not queue one 10 s read each behind this lock.
|
# 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
|
self.tried_at = now
|
||||||
states = await self.hass.states()
|
states = await self.hass.states()
|
||||||
if states is not None:
|
if states is not None:
|
||||||
@@ -71,6 +87,14 @@ class EntityCache:
|
|||||||
return None, None
|
return None, None
|
||||||
return self.rows, now - self.read_at
|
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:
|
def peek(self) -> dict | None:
|
||||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
"""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:
|
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.
|
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.kind = kind
|
||||||
self.problems = list(problems)
|
self.problems = list(problems)
|
||||||
self.changed = list(changed)
|
self.changed = list(changed)
|
||||||
self.stored = stored
|
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})")
|
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, *,
|
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.
|
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||||
|
|
||||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
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,
|
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||||
shows them what changed, and they save again from THAT - which is a
|
shows them what changed, and they save again from THAT - which is a
|
||||||
decision made in front of the difference rather than through it.
|
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)
|
problems = await problems_for(proposed, hass)
|
||||||
if problems:
|
if problems:
|
||||||
@@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
|||||||
actual = fingerprint(stored)
|
actual = fingerprint(stored)
|
||||||
if actual != expect:
|
if actual != expect:
|
||||||
changed = changed_keys(stored, proposed)
|
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 "
|
_LOG.warning("The settings page did not save, because the settings "
|
||||||
"changed while it was open (keys: %s).",
|
"changed while it was open (keys: %s).",
|
||||||
", ".join(changed) or "none this page also edited")
|
", ".join(keys) or "none this page also edited")
|
||||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||||
|
moved=moved)
|
||||||
|
|
||||||
await selfcfg.write_options(proposed)
|
await selfcfg.write_options(proposed)
|
||||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
_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)}
|
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
||||||
@media (max-width:640px){#savebar{bottom:62px}}
|
@media (max-width:640px){#savebar{bottom:62px}}
|
||||||
#s-set{padding-bottom:80px}
|
#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")));
|
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||||
|
|
||||||
// Shared with settings.js, which loads after this file.
|
// 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 ---------------- */
|
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||||
const TABS = [...document.querySelectorAll(".tab")];
|
const TABS = [...document.querySelectorAll(".tab")];
|
||||||
|
|||||||
@@ -117,11 +117,18 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
|
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
|
||||||
<div class="card">
|
<div class="card" id="wz">
|
||||||
<h2 data-t="setup.name"></h2>
|
<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>
|
||||||
<p class="small muted" data-t="setup.description"></p>
|
<div class="steps" id="wz-steps" aria-hidden="true"></div>
|
||||||
<a class="btn" href="config" data-t="settings.open"></a>
|
<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>
|
</div>
|
||||||
|
<p class="tiny muted" data-s="setup.description"></p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
ranges and choices all arrive as data. It posts the page model back; the
|
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
|
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
|
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 () {
|
(function () {
|
||||||
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
||||||
const W = ROOT.settings;
|
const W = ROOT.settings;
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
let DEVICE = "";
|
let DEVICE = "";
|
||||||
let LIST = null; // the inverter list field
|
let LIST = null; // the inverter list field
|
||||||
let FILLED = {}; // field key -> the preset field that fills it
|
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;
|
let uid = 0;
|
||||||
const ROWS = [];
|
const ROWS = [];
|
||||||
const OPEN = new Set();
|
const OPEN = new Set();
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
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 (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();
|
queueValidate();
|
||||||
}
|
}
|
||||||
function findField(key) {
|
function findField(key) {
|
||||||
@@ -290,6 +293,7 @@
|
|||||||
|
|
||||||
/* ---------------- rows, cards and groups ---------------- */
|
/* ---------------- rows, cards and groups ---------------- */
|
||||||
function keyText(f, card) {
|
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);
|
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;
|
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||||
}
|
}
|
||||||
@@ -324,7 +328,7 @@
|
|||||||
} else {
|
} else {
|
||||||
cards.forEach((c, i) => {
|
cards.forEach((c, i) => {
|
||||||
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
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: () => {
|
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;
|
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.
|
// 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;
|
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||||
}
|
}
|
||||||
function paint() {
|
function paint() {
|
||||||
const q = $("#set-search").value.trim().toLowerCase();
|
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||||
const placed = new Set();
|
const placed = new Set();
|
||||||
for (const r of ROWS) {
|
for (const r of ROWS) {
|
||||||
r.visible = visible(r.f);
|
r.visible = visible(r.f);
|
||||||
@@ -451,7 +455,7 @@
|
|||||||
}
|
}
|
||||||
function savebar() {
|
function savebar() {
|
||||||
const n = V ? dirtyCount() : 0;
|
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);
|
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||||
}
|
}
|
||||||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||||
@@ -459,17 +463,21 @@
|
|||||||
/* ---------------- talking to the add-on ---------------- */
|
/* ---------------- talking to the add-on ---------------- */
|
||||||
let vtimer = null, vseq = 0;
|
let vtimer = null, vseq = 0;
|
||||||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
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;
|
const seq = ++vseq;
|
||||||
try {
|
try {
|
||||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
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();
|
const b = await r.json();
|
||||||
if (seq !== vseq) return;
|
if (seq !== vseq) return null;
|
||||||
MSG = b.messages || [];
|
MSG = b.messages || [];
|
||||||
DEVICE = b.device_note || "";
|
DEVICE = b.device_note || "";
|
||||||
|
STEPS = b.steps || {};
|
||||||
paint();
|
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) {
|
function banner(level, title, sub, button, onClick) {
|
||||||
const host = $("#set-banner");
|
const host = $("#set-banner");
|
||||||
@@ -513,7 +521,7 @@
|
|||||||
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
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 if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||||||
else banner();
|
else banner();
|
||||||
render();
|
if (view === "setup") drawStep(); else render();
|
||||||
validate();
|
validate();
|
||||||
}
|
}
|
||||||
function firstBad() {
|
function firstBad() {
|
||||||
@@ -524,6 +532,20 @@
|
|||||||
const input = r.el.querySelector("input,select,button");
|
const input = r.el.querySelector("input,select,button");
|
||||||
if (input) input.focus({ preventScroll: true });
|
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() {
|
async function save() {
|
||||||
const btn = $("#sb-save");
|
const btn = $("#sb-save");
|
||||||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||||
@@ -531,14 +553,10 @@
|
|||||||
clearTimeout(vtimer);
|
clearTimeout(vtimer);
|
||||||
await validate();
|
await validate();
|
||||||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
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" },
|
const { r, b } = await postSave();
|
||||||
body: JSON.stringify({ model: V, fingerprint: FP }) });
|
|
||||||
const b = await r.json().catch(() => ({}));
|
|
||||||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||||
if (r.status === 409 && b.kind === "stale") {
|
if (r.status === 409 && b.kind === "stale") {
|
||||||
FP = b.fingerprint || FP;
|
banner("bad", W.stale.name, staleText(b));
|
||||||
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(", ") + "." : ""));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); 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);
|
await load(); toast(W.toast.discarded);
|
||||||
});
|
});
|
||||||
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
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) => {
|
document.addEventListener("ux13:tab", (e) => {
|
||||||
active = e.detail === "settings";
|
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
|
||||||
if (active && !M) load(); else savebar();
|
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
|
||||||
});
|
});
|
||||||
// app.js chose the first tab before this file ran.
|
// 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"):
|
def __init__(self, path: str = "/data/state.json"):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.data = dict(DEFAULTS)
|
self.data = dict(DEFAULTS)
|
||||||
|
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
|
||||||
|
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
|
||||||
|
self.fresh = False
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
@@ -598,6 +601,7 @@ class Store:
|
|||||||
self.data.get("phase"), self.data.get("last_completed"),
|
self.data.get("phase"), self.data.get("last_completed"),
|
||||||
self.data.get("sign_tripped"))
|
self.data.get("sign_tripped"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
self.fresh = True
|
||||||
_LOG.info("no saved state, starting fresh")
|
_LOG.info("no saved state, starting fresh")
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
# A corrupt state file must not stop the controller: losing the
|
# A corrupt state file must not stop the controller: losing the
|
||||||
|
|||||||
@@ -80,8 +80,7 @@
|
|||||||
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
|
"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."},
|
"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"},
|
"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"},
|
"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."}
|
|
||||||
},
|
},
|
||||||
"choices_later": {
|
"choices_later": {
|
||||||
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
|
"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."},
|
"blocked": {"description": "Fix the settings marked in red, then save again."},
|
||||||
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
|
"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": {
|
"derive": {
|
||||||
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
||||||
"no_inverter": {"description": "Add at least one inverter."},
|
"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."},
|
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
|
||||||
"no_price": {"description": "Enter a price per kWh."},
|
"no_price": {"description": "Enter a price per kWh."},
|
||||||
"unknown": {"description": "The page cannot store this choice, so reload the page."}
|
"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),
|
), 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.
|
# Option keys the page never shows. uimodel.py writes each from page fields.
|
||||||
DERIVED = {
|
DERIVED = {
|
||||||
"setpoint_entity": "inverters", # one inverter, stored flat
|
"setpoint_entity": "inverters", # one inverter, stored flat
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import re
|
|||||||
from . import uilayout as L
|
from . import uilayout as L
|
||||||
from . import uimodel as U
|
from . import uimodel as U
|
||||||
from .configui import help_of, label_of, problems_by_field, split_group
|
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",
|
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||||
"en.json")
|
"en.json")
|
||||||
@@ -101,6 +102,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
|||||||
"help": text, "unit": fld.unit, "scale": fld.scale,
|
"help": text, "unit": fld.unit, "scale": fld.scale,
|
||||||
"step": fld.step, "kind": fld.kind,
|
"step": fld.step, "kind": fld.kind,
|
||||||
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
||||||
|
"wizard": fld.wizard,
|
||||||
"type": value_type, "optional": not node.get("required", False),
|
"type": value_type, "optional": not node.get("required", False),
|
||||||
# A choice mirrored into a field also shows (and finds) its keys.
|
# A choice mirrored into a field also shows (and finds) its keys.
|
||||||
"writes": sorted(k for k, how in L.DERIVED.items()
|
"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)."""
|
"""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)
|
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
|
||||||
if f))
|
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
|
"""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
|
add-on starts. `keys` are the options it is about, so the page can show it
|
||||||
beside them."""
|
beside them."""
|
||||||
tier: str # "warn" or "info"
|
tier: str # "warn" or "info"; the wizard's checks
|
||||||
|
# also "ok" and "bad" (setup_checks)
|
||||||
keys: tuple
|
keys: tuple
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
|||||||
"""
|
"""
|
||||||
notes: list[Note] = []
|
notes: list[Note] = []
|
||||||
if entities is not None:
|
if entities is not None:
|
||||||
for key, where, entity in _entities_read(opts):
|
_entity_notes(notes, opts, entities)
|
||||||
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.")
|
|
||||||
cap = _number(opts, "peak_cap_w", None)
|
cap = _number(opts, "peak_cap_w", None)
|
||||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||||
_advise(notes, "warn", ("peak_cap_w",),
|
_advise(notes, "warn", ("peak_cap_w",),
|
||||||
@@ -1769,6 +1759,256 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
|||||||
return notes
|
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):
|
def _number(opts: dict, key, default):
|
||||||
raw = opts.get(key, default)
|
raw = opts.get(key, default)
|
||||||
if raw is None or raw == "":
|
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
|
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
|
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.
|
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 io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from . import messages, selfconfig, uipage
|
from . import messages, selfconfig, uipage
|
||||||
from .haentities import EntityCache
|
from .haentities import EntityCache
|
||||||
from .homeview import region_of
|
from .homeview import region_of
|
||||||
from .validate import config_notes
|
from .validate import config_notes, setup_checks
|
||||||
from .config_page import CONFIG_PAGE
|
from .config_page import CONFIG_PAGE
|
||||||
from .configui import changed_keys, field_model, problems_by_field
|
from .configui import changed_keys, field_model, problems_by_field
|
||||||
from .uimodel import DeriveError, options_to_ui
|
from .uimodel import DeriveError, options_to_ui
|
||||||
@@ -103,6 +107,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
controller.hass.token)
|
controller.hass.token)
|
||||||
entities = EntityCache(controller.hass)
|
entities = EntityCache(controller.hass)
|
||||||
index_page = page()
|
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):
|
async def index(_request):
|
||||||
return web.Response(text=index_page, content_type="text/html")
|
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)
|
info.get("translations") or {}, lang)
|
||||||
model.update({
|
model.update({
|
||||||
"options": options,
|
"options": options,
|
||||||
"fingerprint": selfconfig.fingerprint(options),
|
"fingerprint": hand_out(options),
|
||||||
"running": controller.o,
|
"running": controller.o,
|
||||||
"running_differs": changed_keys(controller.o, options),
|
"running_differs": changed_keys(controller.o, options),
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
@@ -176,6 +196,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
"layout": uipage.layout(info.get("schema") or [],
|
"layout": uipage.layout(info.get("schema") or [],
|
||||||
info.get("translations") or {}, lang),
|
info.get("translations") or {}, lang),
|
||||||
"ui": options_to_ui(options),
|
"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)
|
return web.json_response(model)
|
||||||
|
|
||||||
@@ -213,8 +239,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
if model is not None:
|
if model is not None:
|
||||||
payload["messages"] = uipage.anchors(problems, notes, model,
|
payload["messages"] = uipage.anchors(problems, notes, model,
|
||||||
_keys_of(options))
|
_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)
|
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):
|
def _keys_of(options):
|
||||||
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
# ⚠️ 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
|
# 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 "")
|
expect = str(body.get("fingerprint") or "")
|
||||||
try:
|
try:
|
||||||
result = await selfconfig.save(selfcfg, options, expect,
|
result = await selfconfig.save(selfcfg, options, expect,
|
||||||
hass=controller.hass)
|
hass=controller.hass,
|
||||||
|
base=handed.get(expect))
|
||||||
except selfconfig.SaveRefused as refused:
|
except selfconfig.SaveRefused as refused:
|
||||||
if refused.kind == "stale":
|
if refused.kind == "stale":
|
||||||
stored = refused.stored or {}
|
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(
|
return web.json_response(
|
||||||
{"kind": "stale", "changed": refused.changed,
|
{"kind": "stale", "changed": refused.changed,
|
||||||
"changed_fields": (uipage.fields_of(refused.changed,
|
"moved": refused.moved,
|
||||||
model)
|
"changed_fields": (uipage.fields_of(moved, model)
|
||||||
if model is not None else []),
|
if model is not None else []),
|
||||||
"stored": stored,
|
"stored": stored,
|
||||||
# The page needs the CURRENT token to be able to save
|
# The page needs the CURRENT token to be able to save
|
||||||
# again after the operator has read the difference. That
|
# again after the operator has read the difference. That
|
||||||
# is not a force door: it takes a second, deliberate press
|
# is not a force door: it takes a second, deliberate press
|
||||||
# made in front of what changed.
|
# made in front of what changed.
|
||||||
"fingerprint": selfconfig.fingerprint(stored)},
|
"fingerprint": hand_out(stored)},
|
||||||
status=409)
|
status=409)
|
||||||
by_key, unanchored = problems_by_field(refused.problems,
|
by_key, unanchored = problems_by_field(refused.problems,
|
||||||
_keys_of(options))
|
_keys_of(options))
|
||||||
@@ -272,6 +322,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
if model is not None:
|
if model is not None:
|
||||||
payload["messages"] = uipage.anchors(refused.problems, [],
|
payload["messages"] = uipage.anchors(refused.problems, [],
|
||||||
model, _keys_of(options))
|
model, _keys_of(options))
|
||||||
|
payload["steps"] = uipage.step_problems(payload["messages"],
|
||||||
|
model)
|
||||||
return web.json_response(payload, status=422)
|
return web.json_response(payload, status=422)
|
||||||
except selfconfig.SupervisorUnavailable as err:
|
except selfconfig.SupervisorUnavailable as err:
|
||||||
_LOG.error("saving options failed: %s", 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 - "
|
controller.log_event("configuration saved from the panel - "
|
||||||
"not applied until the add-on restarts",
|
"not applied until the add-on restarts",
|
||||||
hint=messages.hint("settings_saved"))
|
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})
|
return web.json_response({"ok": True, **result})
|
||||||
|
|
||||||
async def config_restart(request):
|
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
|
"""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."""
|
never an error: when Home Assistant is down the list says so."""
|
||||||
kind = str(request.query.get("kind", "") or "")[:16]
|
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))
|
return web.json_response(await entities.payload(kind))
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
|
|||||||
@@ -920,7 +920,7 @@ DATA = {
|
|||||||
"price_history.py:messages.count(len(points), 'price')",
|
"price_history.py:messages.count(len(points), 'price')",
|
||||||
"price_history.py:err",
|
"price_history.py:err",
|
||||||
"selfconfig.py:messages.count(len(problems), 'problem')",
|
"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",
|
"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",
|
"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)
|
# 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))
|
_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"
|
_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))
|
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
|
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
|
(`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.
|
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
|
import asyncio
|
||||||
@@ -39,6 +45,8 @@ PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
|
|||||||
|
|
||||||
|
|
||||||
class Store(dict):
|
class Store(dict):
|
||||||
|
fresh = False # app/store.py: no state file at this boot
|
||||||
|
|
||||||
def set(self, k, v):
|
def set(self, k, v):
|
||||||
self[k] = v
|
self[k] = v
|
||||||
|
|
||||||
@@ -74,8 +82,12 @@ class Hass:
|
|||||||
async def number(self, _e, invert=False):
|
async def number(self, _e, invert=False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def state(self, _e):
|
async def state(self, e):
|
||||||
return None
|
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):
|
async def set_number(self, _e, _v):
|
||||||
return True
|
return True
|
||||||
@@ -87,6 +99,10 @@ class Hass:
|
|||||||
return STATES
|
return STATES
|
||||||
|
|
||||||
|
|
||||||
|
KETTLE_READS = (-10, 1990, 1990, -10)
|
||||||
|
KETTLE_COUNT = [0]
|
||||||
|
|
||||||
|
|
||||||
def _state(eid, name, state, unit=None, cls=None):
|
def _state(eid, name, state, unit=None, cls=None):
|
||||||
return {"entity_id": eid, "state": state,
|
return {"entity_id": eid, "state": state,
|
||||||
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
|
"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"):
|
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)
|
c = controller(state)
|
||||||
stored = dict(defaults(CONFIG), **OPTS)
|
stored = dict(defaults(CONFIG), **OPTS)
|
||||||
stored.update(setpoint_entity="", meter_source="ha_signed",
|
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