Compare commits

...
Author SHA1 Message Date
Claude review agent 39e6c260fe UX-15 review: DECISIONS states the TEST-23 run that happened (PR #147)
checks / unit (pull_request) Successful in 11m8s
infra-pin / fresh (pull_request) Successful in 1s
It said the PR had TEST-23 evidence, but the PR says TEST-23 was not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:15:51 +02:00
Claude review agent 30cba1e58d UX-15 review: shorter setup banner (PR #147)
The Home banner title was ten words; UX-08 asks for about six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:06:12 +02:00
adminandClaude Opus 5.5 63e7e8a133 UX-15: mutations fail named checks, not crashes
checks / unit (pull_request) Successful in 10m38s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:54:26 +02:00
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00
admin 4de96752ff 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
2026-09-28 06:23:16 +02:00
review-agent 3198f346fd UX-13 P4 review fixes (PR #146, comment 1887)
checks / unit (pull_request) Successful in 10m24s
infra-pin / fresh (pull_request) Successful in 1s
R1: first_start also needs a Store created at this boot (Store.fresh), so
    old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:12:09 +02:00
adminandClaude Opus 5.5 be57b87c34 UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00
admin 515c11b380 Merge pull request 'UX-13 P2: the Installer settings view' (#145) from UX-13-p2-installer into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge UX-13 P2: the installer settings view (#145)
2026-09-28 03:54:23 +02:00
28 changed files with 3054 additions and 92 deletions
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
the reason it stays, and the reason the panel refuses to write a blob the
validator rejected: this surface must never be the thing that removes itself.
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
keeps the panel: options that name no inverter at all and have no other
refusal, which is what a new install ships. The add-on then serves the page in
setup-only mode and commands nothing. Every other refusal still exits 78.
**The panel shows fewer refusals than the startup gate can, never more.** Two
of `validate.py`'s rules compare a configured ceiling against the setpoint
device's *own* reported range, which is only knowable by asking Home Assistant.
@@ -66,6 +66,10 @@ What UX-02 made authoritative stays authoritative:
to the existing `/config/validate` and `/config/save`, which turn it into
options with `ui_to_options()` before the shipped validator runs. No route
was added.
- P4: the setup wizard draws the same model by each field's `wizard` step.
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
names no key. See [[UX-13-setup-wizard]].
## 4. What this costs
@@ -0,0 +1,101 @@
# UX-13 P4 — the setup wizard, and when it opens by itself
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
## 1. What the wizard is
The Setup tab draws the Installer view's page model one step at a time: a
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
controls, `/config/validate` and `/config/save`. No route was added.
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
yet (OPT-11); it says they come later.
A step moves on when no refusal is anchored to one of its shown fields
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
no field at all blocks the last step, which lists every refusal.
## 2. When it opens by itself (`uipage.setup_needed`)
The page opens `#setup` only when it is opened with no route, and only when
`/config/model` gives a reason:
- **`no_power_request`**: the stored options name no inverter to control.
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
Store has no `setup_done` and no `auto` key, and control is not started.
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
setup-only mode (UX-15) 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.
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
naming no inverter is the gate's only refusal, the add-on serves the page in
setup-only mode, which commands nothing, and the wizard opens with
`no_power_request`. Setup-only mode writes no Store file, so the first-run
rule above still holds for an install set up on the Configuration tab.
## 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).
UX-15 added the shipped defaults as a start (setup-only mode). At P4
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
keys each.
@@ -0,0 +1,114 @@
# UX-15 — setup-only mode: a new install serves the wizard
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
## 1. The rule
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
refused by rule 1 of `validate_startup_config()`: no inverter to control.
Until now that was exit 78 before any page was served, and the installer had
to fill in the entity on the Configuration tab before the wizard could open.
The add-on now starts in **setup-only mode** when, and only when
(`setuponly.applies()`):
- the options name no inverter at all: no flat `setpoint_entity` and no
`inverters` entry, and
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
refusal.
In setup-only mode it serves the page, says it is in setup (banner, `/status`
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
opened with no route (`setup_needed` is `no_power_request`). After the
wizard's save and restart the normal gate applies to the saved options.
## 2. What still exits 78, and why
Everything else the gate refuses, including:
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
`max_w`;
- no power request entity **and** any other refusal;
- an `inverters` list whose entry has a blank power request entity.
These are configurations somebody edited. The Configuration tab stored them,
the log's refusal banner names each problem, and the tab is where they are
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
front of someone who needs to change one field. A narrow rule also keeps the
setup-only decision to one comparison against one known sentence, so a new
validator rule cannot widen it by accident: any other refusal fails the
equality and exits 78.
The cost: an installer who first sets, say, a DSMR meter with no entities on
the Configuration tab and leaves the power request entity empty gets exit 78,
not the wizard. The log then names both fields.
## 3. Why it cannot command
Setup-only mode builds nothing that commands. `amain()` branches to
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
the tariff task are built, and it returns from there when the Supervisor
stops it. What serves the page is `SetupOnly`, which holds no inverter.
Three checks make it structural rather than a convention:
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
`call()` refuses before it sends anything, and every service call goes
through it: `number.set_value` (`set_number()`), notifications, all of
them. The flag is fixed at construction, and there is no setter.
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
refuses one that cannot.** So neither can be built on the wrong one.
3. **`/action` answers 409** in setup-only mode, before
`handle_action()`. Start, Stop and the battery cycle do nothing.
No MQTT client is created, so nothing is published. The page's other routes
already read only, or write the add-on's own options (UX-02 §6).
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
as in any mode. So a new install that is set up on the Configuration tab
instead still has a fresh Store on its first normal boot, and the first-run
rule (UX-13 §2) sends it to the wizard as before.
## 4. How it is held
- `test_setup_only.py` part 1: when it applies, including the three exit-78
cases above. Part 2: the read-only write path refuses with nothing sent,
and the two constructors refuse. Part 3 boots the real `main()` on the
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
logs every request: the page is served, `/status` says setup-only, Start
and the cycle get 409, and there are 0 service calls and no MQTT request,
also with `auto_start` on and a Store that says control was started. The
wizard's save is then booted: it passes the normal gate, does not open the
wizard again, and every write is 0 W (shadow). Part 4: the refused
configurations exit 78 and serve nothing.
- Mutations, each red: `applies()` always false or always true, the
read-only refusal removed, the Controller's check removed, `amain()` giving
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
`return` after `serve()` removed.
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
defaults and an empty `/data` made 0 service calls and no MQTT request in
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
and after the restart it ran in shadow: 11 of 11 writes were
`set_value 0.0`.
## 5. The wizard fixes in the same card
- **F1.** `/config/validate` converts what it can of a page model
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
step beside its field and the live checks still run on the rest. A refusal
about a key the failed conversion writes is left out, because it would
judge the stored value. The save still refuses such a model (422, with
`steps`).
- **F2.** A save from the page model sends `base`, the model it opened. The
add-on writes only the keys whose value differs from `base`, onto the blob
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
409 keeps the other surface's changes. A key both surfaces changed takes
this page's value, which is what the second press after the banner means.
- **Meter entity.** On the last step a meter entity Home Assistant does not
have is red, like a missing power request entity, so a skipped kettle test
cannot let it through. In the Installer view it stays a warning.
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
+45
View File
@@ -3221,6 +3221,47 @@ action log (`Controller.events`). The page no longer shows that log, but the
page and add `status` to its address. The action-log tables in this document
refer to it.*
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
inverters and batteries, solar (optional), prices, the peak limit, and a last
check. Each step shows the same fields as Settings, and nothing is saved until
the last step.
- **The kettle test** on the meter step reads the meter, then reads it again
after you switch on a kettle. If the reading goes down, the add-on flips its
sign for you; with a net or import/export meter it tells you what to change.
- **The last step** runs the add-on's own start checks and reads Home
Assistant: the meter reading, that each power request entity exists, the
battery levels, and whether prices are known. A missing power request or
meter 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.
A new install names no inverter yet, so the add-on starts **in setup**: it
serves the page and sends nothing to any inverter. Home says "Setup: nothing
is controlled yet", and there is no Start button. The installer's steps:
1. Install the add-on, turn on **Watchdog** on its Info tab, and start it.
Leave the Configuration tab as it is.
2. Open the add-on's page. Setup opens by itself.
3. Go through the six steps and press **Save and restart**. With "Start in
shadow mode" on, every inverter stays at 0 W after the restart.
4. On Home, check the meter direction and what the add-on would do. Turn
shadow mode off under Settings when it looks right.
Any other problem in the stored settings still stops the add-on (exit code 78),
and the log names each one. Fix those on the Configuration tab.
*For installers: cards UX-13 and UX-15, `DECISIONS/UX-13-setup-wizard.md` and
`DECISIONS/UX-15-setup-only-mode.md`. In setup the `status` JSON has
`setup_only: true`, and no MQTT entity is published.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
@@ -3234,6 +3275,10 @@ inverter stays at 0 W. If the banner is red and says a safety stop stays on,
one of the red rows below is a stop that will still be there when shadow mode
ends. That row says how to clear it.
In **setup** (a new install that names no inverter yet) the banner says
nothing is controlled yet, and the one row says to finish Setup. See *Setup,
for a new install*.
| Row starts with | What it tells you |
|---|---|
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
+27 -3
View File
@@ -13,6 +13,7 @@ CACHE_S = 30.0
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
@@ -28,6 +29,18 @@ def row(state: dict) -> dict | None:
"device_class": attrs.get("device_class")}
def watts(r: dict | None):
"""A power entity's reading in W, or None when it is not a number in a
power unit (the setup wizard's kettle test)."""
if not r or r.get("unit") not in _TO_W:
return None
try:
value = float(r.get("state"))
except (TypeError, ValueError):
return None
return value * _TO_W[r["unit"]] if value == value else None
def matches(r: dict, kind: str) -> bool:
"""Is `r` what a picker of this kind offers first? `kind` is one of
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
@@ -54,13 +67,16 @@ class EntityCache:
self.tried_at: float | None = None
self._lock = asyncio.Lock()
async def get(self) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read."""
async def get(self, max_age: float = CACHE_S
) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read.
`max_age` is how old a try may be before HA is asked again: the setup
wizard's last step asks for a list at most a few seconds old."""
async with self._lock: # one HA read however many pickers ask
now = self.clock()
# A failed read also waits CACHE_S, so polls while HA is down do
# not queue one 10 s read each behind this lock.
if self.tried_at is None or now - self.tried_at >= CACHE_S:
if self.tried_at is None or now - self.tried_at >= max_age:
self.tried_at = now
states = await self.hass.states()
if states is not None:
@@ -71,6 +87,14 @@ class EntityCache:
return None, None
return self.rows, now - self.read_at
async def reading(self, entity_id: str) -> dict:
"""One entity read now, not from the list: the kettle test compares
two readings seconds apart, which a 30 s list cannot. A read only."""
r = row(await self.hass.state(entity_id)) if entity_id else None
return {"entity_id": entity_id, "found": r is not None,
"state": r and r["state"], "unit": r and r["unit"],
"watts": watts(r)}
def peek(self) -> dict | None:
"""The cached rows if they are fresh, without asking Home Assistant."""
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
+19 -1
View File
@@ -51,10 +51,18 @@ class Outage:
OUTAGE = Outage()
class ReadOnly(RuntimeError):
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
*, read_only: bool = False):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
# UX-15: setup-only mode reads Home Assistant and calls no service.
# Fixed at construction; `call()` refuses before it sends anything.
self._read_only = bool(read_only)
self._last_moan: dict[str, float] = {}
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
@@ -181,7 +189,17 @@ class HomeAssistant:
return None
return (_f("min"), _f("max"), _f("step"))
@property
def read_only(self) -> bool:
return self._read_only
async def call(self, domain: str, service: str, data: dict) -> bool:
if self._read_only:
# Every service call, `number.set_value` included, goes through
# here, so setup-only mode has no way to command (DECISIONS UX-15).
_LOG.error("Setup-only mode called no service, because it may not "
"command anything.")
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
+17 -1
View File
@@ -87,9 +87,11 @@ from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import setuponly
from . import web
OPTIONS_PATH = "/data/options.json"
INGRESS_PORT = 8099
_LOG = logging.getLogger("goodwe")
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
@@ -375,6 +377,11 @@ class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller. Refused here so no controller can hold one.
if getattr(hass, "read_only", False) is True:
raise ValueError("a controller cannot run on a read-only "
"HomeAssistant (setup-only mode)")
self.o = opts
self.hass = hass
self.store = store
@@ -6011,6 +6018,15 @@ async def amain() -> None:
_dev_min, _dev_max, _ = await hass.limits(_sp)
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
_problems = validate_startup_config(opts, _dev_min, _dev_max)
# UX-15: a new install names no inverter. When that is the ONLY
# refusal it serves the setup wizard in setup-only mode, which builds
# nothing that commands and returns when stopped. Any other refusal
# still exits 78 (DECISIONS/UX-15-setup-only-mode.md).
if _problems and setuponly.applies(opts, _problems):
await setuponly.serve(
opts, HomeAssistant(session, read_only=True), store,
port=INGRESS_PORT)
return
if _problems:
refuse(_problems)
raise ConfigRefused(_problems)
@@ -6054,7 +6070,7 @@ async def amain() -> None:
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop)
runner = await web.start(controller, port=8099)
runner = await web.start(controller, port=INGRESS_PORT)
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
+13
View File
@@ -382,6 +382,16 @@ def banner_stopped() -> str:
return "Stopped — the inverters are set to 0 W"
def banner_setup_only() -> str:
"""UX-15: setup-only mode, where the add-on serves the page and commands
nothing (app/setuponly.py)."""
return "Setup: nothing is controlled yet"
def row_setup_only() -> str:
return "No inverter is named yet, so finish Setup and restart the add-on."
def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner.
@@ -861,6 +871,7 @@ HINTS = {
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"setup_only": "In setup, nothing is sent to any inverter",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
@@ -952,6 +963,8 @@ def status_samples() -> list:
("banner_peak_reserve", None, banner_peak_reserve()),
("banner_power_too_high", None, banner_power_too_high()),
("banner_shadow", None, banner_shadow(False)),
("banner_setup_only", None, banner_setup_only()),
("row_setup_only", AMBER, row_setup_only()),
("banner_shadow", None, banner_shadow(True)),
("row_controller_starting", GREEN, row_controller_starting()),
("row_controller_ran", GREEN, row_controller_ran(2)),
+19 -4
View File
@@ -131,11 +131,16 @@ class SaveRefused(Exception):
own sentences, byte for byte; `changed` names the keys that moved.
"""
def __init__(self, kind, problems=(), changed=(), stored=None):
def __init__(self, kind, problems=(), changed=(), stored=None,
moved=None):
self.kind = kind
self.problems = list(problems)
self.changed = list(changed)
self.stored = stored
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
# against stored-now - when the caller knows what it opened. None
# when it does not. `changed` also holds the operator's own edits.
self.moved = None if moved is None else list(moved)
super().__init__(f"save refused ({kind})")
@@ -359,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
hass=None) -> dict:
hass=None, base=None) -> dict:
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
@@ -382,6 +387,11 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
genuinely wants what the other surface wrote replaced, the page reloads,
shows them what changed, and they save again from THAT - which is a
decision made in front of the difference rather than through it.
`base`, when given, is the blob the page opened (the one `expect` is the
fingerprint of). A stale refusal then also names the keys that moved
underneath it, so the page does not list the operator's own edits among
the settings "changed elsewhere" (UX-13 P2 test, finding a).
"""
problems = await problems_for(proposed, hass)
if problems:
@@ -395,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
moved = (changed_keys(base, stored)
if isinstance(base, dict) and fingerprint(base) == expect
else None)
keys = changed if moved is None else moved
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
", ".join(keys) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored,
moved=moved)
await selfcfg.write_options(proposed)
_LOG.info("options saved from the ingress panel; a restart is needed "
+90
View File
@@ -0,0 +1,90 @@
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
`applies()` says when: the startup gate's only refusal is that no inverter is
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
controller. Nothing that can command is built: no Controller, no inverter, no
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
before anything is sent (hass.py), and `/action` refuses too (web.py).
"""
import asyncio
import contextlib
import logging
import signal
from datetime import datetime, timezone
from . import messages, web
from .inverter import roster_entries
from .validate import actuator_writers, no_power_request_problem
_LOG = logging.getLogger("goodwe.setup")
def applies(opts: dict, problems) -> bool:
"""True when the options name no inverter at all and that is the gate's
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
return (not actuator_writers(opts) and not roster_entries(opts)
and list(problems) == [no_power_request_problem()])
class SetupOnly:
"""What web.build_app() needs from a controller, and nothing that writes.
It holds no inverter and no publisher. `hass` must be read-only."""
setup_only = True
auto = False # control is never started here
grid = None # no meter is read here
inverters = ()
def __init__(self, opts: dict, hass, store):
if getattr(hass, "read_only", False) is not True:
raise ValueError("setup-only mode needs a read-only HomeAssistant")
self.o, self.hass, self.store = opts, hass, store
self.events: list[str] = []
self.hint = ""
def log_event(self, msg: str, hint: str | None = None) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def status_payload(self) -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
# web.py answers 409 before it gets here. Nothing is done either way.
_LOG.warning("The page asked for an action in setup, so nothing "
"was done.")
return None
async def shutdown(self) -> None:
"""Nothing to wind down: nothing was ever commanded."""
async def serve(opts: dict, hass, store, *, port: int) -> None:
"""Serve the page in setup-only mode until the Supervisor stops us."""
controller = SetupOnly(opts, hass, store)
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
"controls nothing. Open its page to finish setup.")
controller.log_event("setup-only mode: no inverter named, nothing is "
"commanded", hint=messages.hint("setup_only"))
runner = await web.start(controller, port=port)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
try:
await stop.wait()
finally:
await runner.cleanup()
_LOG.info("stopped")
+8
View File
@@ -216,3 +216,11 @@ input:disabled{opacity:.6}
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
@media (max-width:640px){#savebar{bottom:62px}}
#s-set{padding-bottom:80px}
/* UX-13 P4: the setup wizard */
.steps{display:flex;gap:4px;margin:4px 0 12px}
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
.msg.ok{background:var(--ok-bg);color:var(--ok)}
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
.kettle .row{margin-top:8px}
+13 -6
View File
@@ -94,11 +94,13 @@
function render() {
renderPill();
const structured = S.now && S.plan && !S.home_error;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
// UX-15: setup-only mode commands nothing and has no control to offer.
const setup = S.setup_only === true;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
renderControl(structured);
renderDetails(!structured);
renderControl(structured, setup);
renderDetails(!structured && !setup);
renderBatteries();
}
@@ -270,12 +272,15 @@
badge.textContent = n; badge.hidden = !n;
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
}
function renderControl(structured) {
function renderControl(structured, setup) {
const running = structured ? S.control.running : null;
const btn = $("#btn-ctl");
btn.textContent = running === false ? t("control.start") : t("control.stop");
btn.className = "btn" + (running === false ? " primary" : "");
$("#ctl-text").textContent = structured ? S.control.text : "";
btn.hidden = !!setup;
$("#btn-setup").hidden = !setup;
$("#c-ctl .more").hidden = !!setup;
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
$("#hint").textContent = S.hint || "";
const docs = $("#docs-link");
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
@@ -332,7 +337,9 @@
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
// Shared with settings.js, which loads after this file.
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)) };
// `first`: the page opened with no route, so settings.js may open the setup
// wizard on a new install (UX-13 P4).
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
+12 -4
View File
@@ -72,6 +72,7 @@
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
@@ -117,11 +118,18 @@
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card">
<h2 data-t="setup.name"></h2>
<p class="small muted" data-t="setup.description"></p>
<a class="btn" href="config" data-t="settings.open"></a>
<div class="card" id="wz">
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
<div class="steps" id="wz-steps" aria-hidden="true"></div>
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
<div id="wz-banner" aria-live="polite"></div>
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
<div class="row spread" id="wz-nav" style="margin-top:14px">
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
</div>
</div>
<p class="tiny muted" data-s="setup.description"></p>
</section>
</main>
+273 -20
View File
@@ -5,7 +5,9 @@
ranges and choices all arrive as data. It posts the page model back; the
add-on turns it into options and runs its own start checks, and nothing
they refuse is saved. Every word is from app/ui_text or the add-on, and
data is set as text, never as HTML. */
data is set as text, never as HTML.
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
(function () {
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
const W = ROOT.settings;
@@ -28,7 +30,8 @@
let DEVICE = "";
let LIST = null; // the inverter list field
let FILLED = {}; // field key -> the preset field that fills it
let active = false;
let view = null; // "settings" or "setup": the tab drawing V
let STEPS = {}; // wizard step -> refusals that block it
let uid = 0;
const ROWS = [];
const OPEN = new Set();
@@ -60,7 +63,7 @@
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
}
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
if (redraw || f.fills) render(); else paint();
if (redraw || f.fills) redrawView(); else paint();
queueValidate();
}
function findField(key) {
@@ -290,6 +293,7 @@
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
if (view === "setup") return ""; // the wizard shows no keys
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
}
@@ -324,7 +328,7 @@
} else {
cards.forEach((c, i) => {
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), h("span", { class: "key" }, f.key + "[" + i + "]"),
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
// Back to one card, the stored shape returns: a flat inverter stays flat.
@@ -393,7 +397,7 @@
return m.card == null ? r.card === 0 : m.card === r.card;
}
function paint() {
const q = $("#set-search").value.trim().toLowerCase();
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
const placed = new Set();
for (const r of ROWS) {
r.visible = visible(r.f);
@@ -451,7 +455,7 @@
}
function savebar() {
const n = V ? dirtyCount() : 0;
$("#savebar").hidden = !(active && n);
$("#savebar").hidden = !(view === "settings" && n);
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
}
$("#set-search").addEventListener("input", () => { if (V) paint(); });
@@ -459,17 +463,21 @@
/* ---------------- talking to the add-on ---------------- */
let vtimer = null, vseq = 0;
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
async function validate() {
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
// or null when it failed or a newer check overtook it.
async function validate(extra) {
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V }) });
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
const b = await r.json();
if (seq !== vseq) return;
if (seq !== vseq) return null;
MSG = b.messages || [];
DEVICE = b.device_note || "";
STEPS = b.steps || {};
paint();
} catch (e) { /* the next change tries again */ }
return b;
} catch (e) { return null; /* the next change tries again */ }
}
function banner(level, title, sub, button, onClick) {
const host = $("#set-banner");
@@ -513,7 +521,7 @@
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
else banner();
render();
if (view === "setup") drawStep(); else render();
validate();
}
function firstBad() {
@@ -524,6 +532,22 @@
const input = r.el.querySelector("input,select,button");
if (input) input.focus({ preventScroll: true });
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves. `base` is
// the model the page opened: only what changed since then is written, onto
// what is stored now (UX-15 F2).
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
}
// The settings another surface changed (not this page's own edits).
function staleText(b) {
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
}
async function save() {
const btn = $("#sb-save");
btn.disabled = true; btn.textContent = W.savebar.saving;
@@ -531,14 +555,10 @@
clearTimeout(vtimer);
await validate();
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: V, fingerprint: FP }) });
const b = await r.json().catch(() => ({}));
const { r, b } = await postSave();
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
if (r.status === 409 && b.kind === "stale") {
FP = b.fingerprint || FP;
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
banner("bad", W.stale.name, W.stale.description + (names.length ? " " + names.join(", ") + "." : ""));
banner("bad", W.stale.name, staleText(b));
return;
}
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
@@ -552,10 +572,243 @@
await load(); toast(W.toast.discarded);
});
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
/* ---------------- the setup wizard (P4) ---------------- */
const WS = W.setup;
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
function redrawView() { if (view === "setup") drawStep(); else render(); }
function allFields() {
const out = [];
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
return out;
}
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
function watts(w, signed) {
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
return (signed && w < 0 ? "-" : "") + n + " W";
}
function line(tier, text) {
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
}
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
// uipage.kettle() (test_setup.py grades that one) on the page.
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
function kettleOutcome(src, before, after) {
const d = after - before;
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
const o = src[d > 0 ? "up" : "down"];
return { say: o.say, sets: o.sets, d };
}
function kettleOk() {
const src = kettleSource();
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
}
async function readMeter(src) {
let total = 0;
for (const [key, sign] of src.read) {
if (!V[key]) return null;
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
if (!r || r.watts == null) return null;
total += sign * r.watts;
}
return total;
}
function kettleBox() {
const src = kettleSource();
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
const sig = kettleSig(src);
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
// A skip after the reading went down still says it went down (UX-15).
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
const words = said ? WS.kettle[said] : 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;
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
ask(dlg, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
const { r, b: body } = res;
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
if (r.status === 422) {
MSG = body.messages || []; STEPS = body.steps || {};
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
if (first) WZ.step = first;
drawStep(); toast(W.toast.blocked.description); firstBad();
return;
}
if (!r.ok) { toast(W.toast.failed); return; }
let restarted = true;
try {
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "restart" }) });
restarted = rr.ok;
} catch (e) { /* the add-on stops answering while it restarts */ }
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
drawStep();
if (!restarted) await load("saved"); // still running: read what is stored
});
}
function drawDone() {
const w = WS[WZ.done];
$("#wz-count").textContent = "";
$("#wz-steps").replaceChildren();
$("#wz-desc").textContent = "";
$("#wz-nav").hidden = true;
ROWS.length = 0;
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
else wzBanner("ok", w.name, w.description);
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
}
function drawStep() {
if (!M) return;
if (WZ.done) { drawDone(); return; }
const S = M.setup, n = WZ.step, words = WS.steps[n];
$("#wz-nav").hidden = false;
$("#wz-title").textContent = words.name;
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
$("#wz-desc").textContent = words.description;
ROWS.length = 0;
const body = [];
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
if (n === 1) body.push(kettleBox());
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
if (n === S.final) body.push(...finalBox());
$("#wz-body").replaceChildren(...body);
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
paint();
}
$("#wz-back").addEventListener("click", () => {
if (WZ.step === 1) { location.hash = "#home"; return; }
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
});
$("#wz-next").addEventListener("click", async () => {
if (!M) return;
const btn = $("#wz-next");
btn.disabled = true;
try {
const n = WZ.step;
if (n === M.setup.final) { await finish(); return; }
clearTimeout(vtimer);
const b = await validate();
if (!b) { toast(W.toast.failed); return; }
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
} finally { btn.disabled = false; }
});
document.addEventListener("ux13:tab", (e) => {
active = e.detail === "settings";
if (active && !M) load(); else savebar();
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
});
// app.js chose the first tab before this file ran.
if ($("#t-set").getAttribute("aria-selected") === "true") { active = true; load(); }
const sel = document.querySelector(".tab[aria-selected=true]");
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
// First run: opened with no route, on a site the add-on says is new
// (uipage.setup_needed), the page goes to Setup by itself.
else if (window.UX13.first) {
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
}
})();
+9 -2
View File
@@ -581,10 +581,16 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
return rep
STATE_PATH = "/data/state.json"
class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
def __init__(self, path: str | None = None):
self.path = path or STATE_PATH
self.data = dict(DEFAULTS)
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
self.fresh = False
self.load()
def load(self) -> None:
@@ -598,6 +604,7 @@ class Store:
self.data.get("phase"), self.data.get("last_completed"),
self.data.get("sign_tripped"))
except FileNotFoundError:
self.fresh = True
_LOG.info("no saved state, starting fresh")
except (json.JSONDecodeError, OSError) as err:
# A corrupt state file must not stop the controller: losing the
+60 -3
View File
@@ -79,9 +79,9 @@
},
"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."},
"setup_only": {"description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"},
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
@@ -148,7 +148,7 @@
"blocked": {"description": "Fix the settings marked in red, then save again."},
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
},
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note"},
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
"derive": {
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
"no_inverter": {"description": "Add at least one inverter."},
@@ -157,6 +157,63 @@
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
"no_price": {"description": "Enter a price per kWh."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
},
"setup": {
"name": "Setup",
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
"step": "Step {n} of {total}",
"back": "Back",
"next": "Next",
"skip": "Next, or skip",
"finish": "Save and restart",
"not_now": "Not now",
"steps": {
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
},
"modes_later": {"description": "Choosing a mode for each battery comes in a later update."},
"kettle": {
"name": "Check the direction",
"description": "Read the meter, switch on a kettle, then read the meter again.",
"read": "Read the meter",
"again": "Read again",
"skip": "Skip this test",
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction 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"},
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
"home": "Open Home"
}
}
}
+28
View File
@@ -234,6 +234,34 @@ GROUPS = (
), support_only=True),
)
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
# --------------------------------------------------------------------------- #
WIZARD_STEPS = 6
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
# read before and after a kettle is switched on, each with the sign it adds to
# the grid reading, and what each direction means as (outcome, fields it
# sets). Sets None: the options cannot fix it, so the step is not passed. A
# source not listed is read by the add-on itself, so the page cannot test it.
KETTLE_SOURCE = "meter_source"
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
KETTLE = {
"off": ((("meter_entity", 1),),
{"up": ("ok", {"meter_invert": False}),
"down": ("flip", {"meter_invert": True})}),
"ha_signed": ((("p1_net_entity", 1),),
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
}
# Step 6 starts the site in shadow mode unless the installer switches it off:
# (field, value when on, value when off).
SHADOW = ("shadow_mode", "on", "auto")
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
+22 -3
View File
@@ -20,7 +20,8 @@ import json
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
"REGION_PRESETS",
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
@@ -32,6 +33,7 @@ class DeriveError(ValueError):
def __init__(self, text, code="", field=""):
super().__init__(text)
self.code, self.field = code, field
self.keys = () # the option keys the failed derivation writes
# The page model's inverter count, which show conditions read, and what adding
@@ -337,14 +339,31 @@ def options_to_ui(options: dict) -> dict:
def ui_to_options(model: dict) -> dict:
"""The options blob for a page model. Raises DeriveError on a model no blob
can express, so the page says so instead of saving something else."""
out, errors = ui_to_options_partial(model)
if errors:
raise errors[0]
return out
def ui_to_options_partial(model: dict):
"""(blob, [DeriveError]): every derivation that can be made, and the
stored values (`keep`) for each one that cannot. For the checks only, so a
step can be blocked by its own field and the rest still checked (UX-15
F1). Never saved: `ui_to_options()` raises instead."""
keep = model.get("keep") or {}
out = {k: copy.deepcopy(v) for k, v in model.items()
if k not in _MODEL_ONLY and k not in _OWNED
and not k.startswith("ui.")}
errors = []
for keys, decode, encode in DERIVATIONS:
was = decode(keep)
if all(model.get(name) == value for name, value in was.items()):
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
else:
continue
try:
out.update(encode(model, keep))
return out
except DeriveError as err:
err.keys = keys
errors.append(err)
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
return out, errors
+118 -1
View File
@@ -17,6 +17,7 @@ import re
from . import uilayout as L
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
@@ -101,6 +102,7 @@ def _field(fld, schema_ui, translations, lang, T):
"help": text, "unit": fld.unit, "scale": fld.scale,
"step": fld.step, "kind": fld.kind,
"show": [list(c) for c in fld.show], "applies": fld.applies,
"wizard": fld.wizard,
"type": value_type, "optional": not node.get("required", False),
# A choice mirrored into a field also shows (and finds) its keys.
"writes": sorted(k for k, how in L.DERIVED.items()
@@ -184,7 +186,32 @@ def to_options(model: dict, stored: dict | None = None) -> dict:
"""The options blob for a posted page model. A value equal to the stored
one is written as stored, in the stored key order, so a save that changed
nothing writes an identical blob (the browser reads 3.0 as 3)."""
out = U.ui_to_options(model)
return _settle(U.ui_to_options(model), stored)
def to_options_partial(model: dict, stored: dict | None = None):
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
settled like `to_options`. Never saved (UX-15 F1)."""
out, errors = U.ui_to_options_partial(model)
return _settle(out, stored), errors
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
"""The blob to save: `stored`, with the keys whose value in `mine` (this
page's model) differs from `base` (the model it opened) set to `mine`'s.
A key this page never edited keeps what is stored now, even when another
surface changed it meanwhile (UX-13 P4 test, F2)."""
edits = {k: v for k, v in mine.items()
if k not in base or not _same(v, base[k])}
gone = {k for k in base if k not in mine}
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
if k not in gone}
out.update({k: copy.deepcopy(v) for k, v in edits.items()
if k not in out})
return out
def _settle(out: dict, stored: dict | None) -> dict:
stored = stored or {}
for k, v in out.items():
if k in stored and _same(v, stored[k]):
@@ -200,6 +227,21 @@ def derive_message(err: U.DeriveError) -> dict:
"text": text}
def derive_messages(errors, problems, notes, model, keys):
"""The page's messages when part of the model could not become options:
each conversion's own sentence beside its field, then the checks of the
rest. A refusal or note about a key the failed conversion writes is left
out, because it judges the stored value, not what the page shows."""
failed = {k for err in errors for k in err.keys}
by_key, unanchored = problems_by_field(problems, keys)
kept = [t for k, texts in by_key.items() if k not in failed
for t in texts] + list(unanchored)
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err) for err in errors]
+ anchors(kept, notes, model, keys))
# --------------------------------------------------------------------------- #
# Where a sentence goes on the page
# --------------------------------------------------------------------------- #
@@ -278,3 +320,78 @@ def fields_of(keys, model) -> list:
"""The page fields that show these option keys (a stale save's list)."""
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
if f))
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
# --------------------------------------------------------------------------- #
# Store keys (app/store.py), not options. SETUP_DONE is written when the
# wizard saves. STARTED is the one control's Start and Stop write.
SETUP_DONE = "setup_done"
STARTED = "auto"
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
"""Why the page opens the wizard by itself, or None.
"no_power_request": the stored options name no inverter to control, so
the add-on cannot start on them. "first_start": a new install. This boot
found no Store file (`fresh`), the wizard never saved and control was never
started or stopped. `fresh` is what tells a new install from an old site
that was never started, e.g. one left in shadow for weeks (TEST-23).
`store_data` is the Store's dict (None: unknown, so no), `started` is
whether control is started now."""
if not power_request_entities(stored or {}):
return "no_power_request"
if store_data is None or not fresh:
return None
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
return None
return "first_start"
def setup_spec() -> dict:
"""What the wizard needs beyond each field's step: read off the table."""
kettle = {}
for source, (read, outcomes) in L.KETTLE.items():
kettle[source] = {"read": [[f, sign] for f, sign in read]}
for way, (say, sets) in outcomes.items():
kettle[source][way] = {"say": say, "sets": sets}
field, on, off = L.SHADOW
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
"optional": list(L.OPTIONAL_STEPS),
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
"sources": kettle},
"shadow": {"field": field, "on": on, "off": off}}
def kettle(source, before_w, after_w):
"""The kettle test's verdict, as the page reaches it (settings.js
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
for a source the page cannot read."""
spec = L.KETTLE.get(source)
if spec is None:
return None
change = float(after_w) - float(before_w)
if abs(change) < L.KETTLE_MIN_W:
return "none", None, change
say, sets = spec[1]["up" if change > 0 else "down"]
return say, (dict(sets) if sets is not None else None), change
def step_of(field_key, model) -> int:
"""The wizard step a sentence on this page field blocks: the field's own
step while it is shown, else the last step, which lists everything."""
fld = L.field(field_key) if field_key else None
if fld is None or not fld.wizard or not L.visible(fld, model):
return L.FINAL_STEP
return fld.wizard
def step_problems(messages, model) -> dict:
"""{"1": n, ...}: how many refusals block each wizard step."""
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
for m in messages:
if m.get("tier") == "bad":
out[str(step_of(m.get("field"), model))] += 1
return out
+275 -22
View File
@@ -374,7 +374,8 @@ class Note(NamedTuple):
"""UX-13: a warning or info for the settings page. Never a refusal: the
add-on starts. `keys` are the options it is about, so the page can show it
beside them."""
tier: str # "warn" or "info"
tier: str # "warn" or "info"; the wizard's checks
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
@@ -807,6 +808,14 @@ class ConfigRefused(Exception):
super().__init__(problems[0] if problems else "configuration refused")
def no_power_request_problem() -> str:
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
starts when this is the only refusal (app/setuponly.py)."""
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
"the inverter. Set it to the ESPHome number entity for the "
"setpoint.")
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
notes: list | None = None) -> list[str]:
"""Return the list of reasons this configuration must not be started.
@@ -827,9 +836,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
writers = actuator_writers(opts)
if not writers:
problems.append(
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
"inverter. Set it to the ESPHome number entity for the setpoint.")
problems.append(no_power_request_problem())
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
# count of option keys. Read `actuator_writers()` for why the old check went
# green on the very config this one is here to refuse.
@@ -1703,6 +1710,14 @@ WATT_ENTITY_KEYS = (
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
# The meter entities each `meter_source` reads through Home Assistant.
METER_ENTITY_KEYS = {
"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities", "p1_phase_export_entities")}
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
"""(key, inverter name or "", entity id) for each entity the add-on reads
with these options. Keys the chosen meter source or roster shape ignores
@@ -1715,12 +1730,7 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
out.append((key, where, e.strip()))
source = str(opts.get("meter_source") or "off").strip()
by_source = {"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities",
"p1_phase_export_entities")}
for key in by_source.get(source, ()):
for key in METER_ENTITY_KEYS.get(source, ()):
add(key, opts.get(key))
entries = roster_entries(opts)
if not entries:
@@ -1749,18 +1759,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
"""
notes: list[Note] = []
if entities is not None:
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
_entity_notes(notes, opts, entities)
cap = _number(opts, "peak_cap_w", None)
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
@@ -1769,6 +1768,260 @@ def config_notes(opts: dict, *, entities: dict | None = None,
return notes
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
"""A note for each entity these options read that Home Assistant does not
have, or that reads in kW where watts are taken. A missing entity whose
key is in `bad` is a "bad" note, which blocks the setup wizard."""
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
# --------------------------------------------------------------------------- #
# UX-13 P4: the setup wizard's last step
# --------------------------------------------------------------------------- #
# Below this a battery or solar reading counts as idle for a sign check.
IDLE_W = 50.0
# Meter + solar + battery is the house's use, never below zero. Below minus
# this, read seconds apart, a direction is reversed (a battery giving power).
BALANCE_W = 300.0
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
# MQTT) is only known live when the running options read the same one.
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
def power_request_entities(opts: dict) -> list:
"""The power request entity of each inverter these options name: the flat
one, or each entry's. Blank ones are left out."""
entries = roster_entries(opts)
if not entries:
values = [opts.get("setpoint_entity")]
else:
values = [e.get("setpoint_entity") for e in entries
if isinstance(e, dict)]
return [str(v).strip() for v in values
if isinstance(v, str) and v.strip()]
def _reading(entities, entity, convert=False):
"""(number, unit) of an entity's state, or (None, state text). The number
is as the add-on reads it: raw for the meter and battery power, which it
takes as watts, and in W for solar (`convert`), which pv.py converts."""
row = (entities or {}).get(entity) or {}
unit = str(row.get("unit") or "")
try:
value = float(row.get("state"))
except (TypeError, ValueError):
return None, str(row.get("state") or "nothing")
if value != value or value in (float("inf"), float("-inf")):
return None, str(row.get("state"))
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
def _signed(w) -> str:
return ("-" if w < 0 else "") + messages.watts(w)
def _cards(opts: dict) -> list:
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
entries = roster_entries(opts)
if not entries:
return [("", {k: opts.get(k) for k in (
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
out = []
for i, entry in enumerate(entries):
if isinstance(entry, dict):
name = _unit_label(opts, i).strip().strip("()")
out.append((f" for {name}" if name else "", entry))
return out
def setup_checks(opts: dict, *, entities: dict | None, running=None,
meter_now_w=None, prices_known=None) -> list[Note]:
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
Live checks on the proposed options: the meter reads, each power request
entity exists in Home Assistant (a write to a missing one is answered 200,
SAFETY-77, so only the entity list can tell), the battery levels read, and
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
request or meter 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:
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
_entity_notes(notes, opts, entities,
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
meter = _meter_check(notes, opts, entities, running, meter_now_w)
batts = [_card_checks(notes, who, entry, entities)
for who, entry in _cards(opts)]
solar = []
if entities is not None:
flipped = pv.inverted(opts)
for entity in pv.entities(opts):
w, _unit = _reading(entities, entity, convert=True)
if entity not in entities or w is None:
solar.append(None)
continue
if w < -IDLE_W and entity not in flipped:
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
known = [meter] + batts + solar
if solar and batts and all(w is not None for w in known) \
and sum(known) < -BALANCE_W:
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
_price_check(notes, opts, running, prices_known)
return notes
def _meter_check(notes, opts, entities, running, meter_now_w):
"""The meter line. Returns the grid reading in W (positive: taken from
the grid) as the add-on will read it, or None when it is not known."""
source = str(opts.get("meter_source") or "off").strip()
if source == "off":
parts = [(opts.get("meter_entity"), 1.0)]
elif source == SOURCE_HA_SIGNED:
parts = [(opts.get("p1_net_entity"), 1.0)]
elif source == SOURCE_HA:
parts = [(opts.get("p1_import_entity"), 1.0),
(opts.get("p1_export_entity"), -1.0)]
else:
same = isinstance(running, dict) and all(
running.get(k) == opts.get(k) for k in _METER_KEYS)
if same and meter_now_w is not None:
_meter_line(notes, float(meter_now_w))
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
return None
if entities is None:
return None
total = 0.0
for entity, sign in parts:
entity = str(entity or "").strip()
if not entity or entity not in entities:
return None # a refusal or a missing-entity note says so
w, text = _reading(entities, entity)
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
total = -total
_meter_line(notes, total)
return total
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
def _card_checks(notes, who, entry, entities):
"""The checks for one inverter's card. Returns its battery power in W,
positive while giving power, or None when it is not known."""
if entities is None:
return None
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
else:
doing = "giving power" if w > 0 else "charging"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
return w
return None
def _price_check(notes, opts, running, prices_known) -> None:
provider = str(opts.get("price_provider") or "off").strip()
profile = str(opts.get("static_profile") or "").strip()
if provider in ("", "off"):
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+110 -17
View File
@@ -28,19 +28,25 @@ do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
of Home Assistant's entity list (app/haentities.py) and writes nothing.
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
not options is a Store key, `setup_done`, written when the wizard's save
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
commands nothing; there `/action` answers 409.
"""
import io
import json
import logging
import os
from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .uimodel import DeriveError, options_to_ui
@@ -103,6 +109,22 @@ def build_app(controller, selfcfg=None) -> web.Application:
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
# save can name only the keys that moved underneath the page (finding a).
handed: dict = {}
def hand_out(options):
fp = selfconfig.fingerprint(options)
handed.pop(fp, None)
handed[fp] = options
while len(handed) > 16:
handed.pop(next(iter(handed)))
return fp
def store_data():
store = getattr(controller, "store", None)
data = getattr(store, "data", None)
return data if isinstance(data, dict) else None
async def index(_request):
return web.Response(text=index_page, content_type="text/html")
@@ -116,6 +138,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
what = body.get("action")
if getattr(controller, "setup_only", False):
# UX-15: nothing to start or stop until setup is saved.
return web.json_response(
{"ok": False, "error": messages.row_setup_only()}, status=409)
result = await controller.handle_action(what)
if result is None:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
@@ -129,14 +155,23 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""(body, options, model). UX-13's settings view posts its page
`model`, which app/uimodel.py turns into the options blob here, so the
validator and the Supervisor only ever see a real blob. The classic
page posts `options`. Raises DeriveError for a model no blob can say."""
page posts `options`. Raises DeriveError for a model no blob can say.
With `stored` (a save) and the `base` model the page opened, only the
keys this page changed are written onto what is stored now, so a save
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
return body, uipage.to_options(model, stored), model
mine = uipage.to_options(model, stored)
base = body.get("base")
if stored is not None and isinstance(base, dict):
was, _errors = uipage.to_options_partial(base, stored)
mine = uipage.only_edits(mine, was, stored)
return body, mine, model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
@@ -165,7 +200,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
info.get("translations") or {}, lang)
model.update({
"options": options,
"fingerprint": selfconfig.fingerprint(options),
"fingerprint": hand_out(options),
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
@@ -176,6 +211,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
options, store_data(), bool(getattr(controller, "auto", False)),
fresh=getattr(getattr(controller, "store", None), "fresh",
False) is True),
# UX-15: this process serves setup only and commands nothing.
"setup_only": getattr(controller, "setup_only", False) is True,
})
return web.json_response(model)
@@ -193,11 +236,19 @@ def build_app(controller, selfcfg=None) -> web.Application:
be a second opinion about a rule it does not own.
"""
try:
_body, options, model = await _proposed(request)
except DeriveError as err:
return web.json_response({"problems": [], "by_key": {},
"unanchored": [], "notes": [],
"messages": [uipage.derive_message(err)]})
_body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = _body.get("model")
errors = []
if isinstance(model, dict):
# UX-15 F1: a part the page cannot turn into options blocks its
# own step, and everything else is still checked.
options, errors = uipage.to_options_partial(model)
else:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -211,10 +262,31 @@ def build_app(controller, selfcfg=None) -> web.Application:
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = uipage.anchors(problems, notes, model,
_keys_of(options))
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options)) if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
return web.json_response(payload)
async def _setup_checks(options):
"""The wizard's live checks: a list at most a few seconds old, and
what the running add-on already knows (its meter, today's prices)."""
rows, age = await entities.get(max_age=5.0)
if age is not None and age >= 30.0:
rows = None # Home Assistant is not answering
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
meter_now_w=getattr(controller, "grid", None),
prices_known=known)
def _keys_of(options):
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
# sound only because the page posts the WHOLE blob it read - which is
@@ -241,29 +313,38 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError as err:
except DeriveError:
model = posted["model"]
_opts, errors = uipage.to_options_partial(model, stored_now)
messages_ = [uipage.derive_message(e) for e in errors]
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": [uipage.derive_message(err)]},
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
hass=controller.hass)
hass=controller.hass,
base=handed.get(expect))
except selfconfig.SaveRefused as refused:
if refused.kind == "stale":
stored = refused.stored or {}
# The fields to look at are the ones the OTHER surface moved,
# not this page's own edits (UX-13 P2 test, finding a).
moved = (refused.changed if refused.moved is None
else refused.moved)
return web.json_response(
{"kind": "stale", "changed": refused.changed,
"changed_fields": (uipage.fields_of(refused.changed,
model)
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
# made in front of what changed.
"fingerprint": selfconfig.fingerprint(stored)},
"fingerprint": hand_out(stored)},
status=409)
by_key, unanchored = problems_by_field(refused.problems,
_keys_of(options))
@@ -272,6 +353,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
if model is not None:
payload["messages"] = uipage.anchors(refused.problems, [],
model, _keys_of(options))
payload["steps"] = uipage.step_problems(payload["messages"],
model)
return web.json_response(payload, status=422)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
@@ -279,6 +362,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
# `handed` keeps `expect`: another page may have opened the same blob.
store = getattr(controller, "store", None)
if (model is not None and body.get("setup") == "done"
and store is not None):
store.set(uipage.SETUP_DONE,
datetime.now(timezone.utc).isoformat(timespec="seconds"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -310,6 +399,10 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
one = str(request.query.get("entity", "") or "")[:255]
if one:
# UX-13 P4's kettle test: one entity, read now.
return web.json_response(await entities.reading(one))
return web.json_response(await entities.payload(kind))
app = web.Application()
+1 -1
View File
@@ -920,7 +920,7 @@ DATA = {
"price_history.py:messages.count(len(points), 'price')",
"price_history.py:err",
"selfconfig.py:messages.count(len(problems), 'problem')",
"selfconfig.py:', '.join(changed) or 'none this page also edited'",
"selfconfig.py:', '.join(keys) or 'none this page also edited'",
"static_tariff.py:e", "store.py:err", "store.py:key", "store.py:raw",
"tariff.py:self.path", "tariff.py:err", "tariff.py:start", "tariff.py:month",
# validate.py: option values and keys (the refusals are part 3's)
+4 -2
View File
@@ -429,9 +429,11 @@ async def _stale(s, root, sup):
_r, _sup = asyncio.run(session(TWO, _stale))
check("409 with the page fields to look at, and nothing written",
check("409 with the page fields to look at, and nothing written. Only the "
"field the other surface changed, not the page's own edit (P4, P2 "
"test finding a)",
_r[0] == 409 and _r[1]["kind"] == "stale"
and "peak_cap_w" in _r[1]["changed_fields"] and _sup.writes == []
and _r[1]["changed_fields"] == ["peak_cap_w"] and _sup.writes == []
and _r[1]["fingerprint"] == selfconfig.fingerprint(_sup.options))
# --------------------------------------------------------------------------- #
+888
View File
@@ -0,0 +1,888 @@
"""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. Part 9 UX-15's fixes: F1, F2 with two tabs, a missing meter entity and
step 1 after a skip. 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, ContentTypeError, 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"]
WS_D = UI_TEXT["settings"]["derive"]
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:
try:
return r.status, await r.json()
except (ValueError, ContentTypeError): # e.g. a plain-text 500
return r.status, {}
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])
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
check(f"every plan from a new install as shipped (no power request entity, "
f"setup-only mode) is accepted ({len(_empty)} plans)",
len(_empty) == len(_new) and not [n for n, r in _empty 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) >= 100)
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, "base": m["ui"],
"fingerprint": m["fingerprint"]})
again = await _post(s, root + "config/save",
{"model": model, "base": m["ui"],
"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 what this page would write) is only the "
"page's own edit, since a save writes only that (UX-15 F2)",
_first_r[1]["changed"] == ["price_timeout_s"])
check("saving again with the new token is the deliberate second press: it "
"stores this page's own edit and keeps the other surface's change "
"(UX-15 F2)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 5000
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("\n9. UX-15: F1, F2, a missing meter entity, and step 1 after a skip")
# --------------------------------------------------------------------------- #
async def _f1(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
price = copy.deepcopy(m["ui"])
price.update({"ui.contract": "daynight", "ui.fallback": "daynight",
"ui.day_price": None, "ui.night_price": 0.25})
none = copy.deepcopy(m["ui"])
none["inverters"] = []
none[U.COUNT] = 0
return {"price": (await _post(s, root + "config/validate",
{"model": price, "setup": True}))[1],
"none": (await _post(s, root + "config/validate",
{"model": none}))[1],
"save": await _post(s, root + "config/save", {
"model": price, "base": m["ui"], "setup": "done",
"fingerprint": m["fingerprint"]})}
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
SITE, _f1))
print(f" empty day price: steps {_r['price'].get('steps')}")
check("F1: a price the page cannot turn into options blocks step 4, beside "
"its own field",
_r["price"].get("steps", {}).get("4", 0) >= 1
and {"field": "ui.day_price", "card": None, "tier": "bad",
"text": WS_D["no_price"]["description"]} in _r["price"].get("messages", []))
check("F1: ...and the last step's live checks still run on the rest",
[x["tier"] for x in _r["price"].get("setup", [])
if "number.inv1_power" in x["text"]] == ["ok"]
and any("402 W" in x["text"] for x in _r["price"]["setup"]))
check("F1: no inverter card blocks step 2, not the last step alone",
_r["none"].get("steps", {}).get("2", 0) >= 1)
check("F1: the save refuses it (422) and says which step to go back to",
_r["save"][0] == 422 and _r["save"][1]["steps"]["4"] >= 1
and _sup.writes == [])
async def _tabs(s, root, sup):
"""Two tabs open one blob. A saves a peak limit; B edited only the
timeout, gets the 409, and presses Save again."""
a = await _get(s, root + "config/model?lang=en")
b = await _get(s, root + "config/model?lang=en")
va = copy.deepcopy(a["ui"])
va["peak_cap_w"] = 4000
ra = await _post(s, root + "config/save", {
"model": va, "base": a["ui"], "fingerprint": a["fingerprint"]})
after_a = copy.deepcopy(sup.options)
vb = copy.deepcopy(b["ui"])
vb["price_timeout_s"] = 12
first = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": b["fingerprint"]})
again = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": first[1]["fingerprint"]})
return ra, after_a, first, again
(_ra, _after_a, _first_b, _again_b), _sup = asyncio.run(
session(Controller(SITE, STATES), dict(SITE, peak_cap_w=3600), _tabs))
_moved = sorted(k for k in _sup.options
if _sup.options[k] != _after_a.get(k))
print(f" A: {_ra[0]}, B: {_first_b[0]} then {_again_b[0]}; B's re-save "
f"moved {_moved}")
check("F2: two tabs. A saves peak 4.0; B, which edited only the timeout, "
"gets the 409 naming the peak limit",
_ra[0] == 200 and _first_b[0] == 409
and _first_b[1]["changed_fields"] == ["peak_cap_w"])
check("F2: B's second press keeps A's peak 4.0 and writes only B's own key",
_again_b[0] == 200 and _sup.options["peak_cap_w"] == 4000
and _sup.options["price_timeout_s"] == 12
and _moved == ["price_timeout_s"])
_both = uipage.only_edits({"a": 2, "b": 1, "c": 5}, {"a": 1, "b": 1, "c": 3},
{"a": 1, "b": 9, "c": 4, "d": 0})
check("F2: only_edits() keeps every stored key the page did not edit, and a "
"key both edited takes this page's value (the deliberate press)",
_both == {"a": 2, "b": 9, "c": 5, "d": 0}
and list(_both) == ["a", "b", "c", "d"])
_gone = tiers(setup_checks(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS))
check("a meter entity Home Assistant does not have is red on the last step, "
"so the wizard does not save it with the kettle test skipped",
("bad", "Home Assistant has no entity sensor.gone, which “Grid power "
"entity” (meter_entity) names.") in _gone)
_net_gone = tiers(setup_checks(dict(SITE, meter_source="ha_signed",
p1_net_entity="sensor.gone"),
entities=ROWS))
check("...for a net or import and export meter too",
[t for t, x in _net_gone if "sensor.gone" in x] == ["bad"])
from app.validate import config_notes # noqa: E402
check("...while the Installer view keeps it a warning, which never blocks "
"a save there",
[n.tier for n in config_notes(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS)
if "sensor.gone" in n.text] == ["warn"])
check("step 1 after a skip that followed a fall says plainly that the "
"reading went down",
'k.say === "skipped" && k.wrong ? "skipped_down" : k.say' in JS
and "went down" in WS["kettle"]["skipped_down"]["description"]
and "not tested" not in WS["kettle"]["skipped_down"]["description"])
check("in setup-only mode the wizard's restart dialog does not claim a 0 W "
"wind-down that nothing runs, and the shadow one says 0 W",
"M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live)" in JS
and "0 W" in WS["dialog_setup"]["description"]
and "0 W" not in WS["dialog_setup_live"]["description"])
check("the page posts the model it opened with every save (F2)",
"model: V, base: ORIG, fingerprint: FP" in JS)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+494
View File
@@ -0,0 +1,494 @@
"""UX-15: setup-only mode. `python3 test_setup_only.py`
A new install names no inverter, and instead of exiting 78 it serves the setup
wizard and commands nothing (DECISIONS/UX-15-setup-only-mode.md).
Part 1 checks when it applies. Part 2 the write path refuses in that mode.
Part 3 boots the REAL `app.main.main()` in a child process (this file with
`--driver`) against a fake Supervisor and Home Assistant over HTTP, and logs
every request they get: the shipped defaults serve the wizard with no service
call, also with `auto_start` on, a Store that says control was started and a
Start pressed on the page; the wizard saves; the next boot passes the normal
gate and runs in shadow. Part 4: a refused but complete configuration still
exits 78 and serves nothing.
Takes ~25 s. Mutating a module this file grades? Read MUTATION.md first.
"""
import asyncio
import copy
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
DEADLINE_S = 60
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def defaults():
import ux13_demo as D
return D.defaults(D.CONFIG)
def state(eid, value, unit="W", cls=None, **attrs):
return {"entity_id": eid, "state": value,
"attributes": dict({"friendly_name": eid,
"unit_of_measurement": unit,
"device_class": cls}, **attrs)}
STATES = [state("sensor.grid", "402"),
state("number.inv1_power", "0", min=-2500, max=2500, step=1),
state("sensor.inv1_level", "62", "%", "battery"),
state("sensor.inv1_battery", "0")]
# --------------------------------------------------------------------------- #
# The child: the real main(), with only the outside world faked over HTTP.
# --------------------------------------------------------------------------- #
def driver(scenario, work):
"""`work` holds options.json, state.json and the files the parent reads.
Every request the fakes get is streamed as `EV [...]` lines."""
import aiohttp
from aiohttp import web as aioweb
from aiohttp.abc import AbstractResolver
import app.main as M
import app.store
import app.web
import ux13_demo as D
import ux13_wizard_blobs as B
import translations_lint as TL
def event(*e):
print("EV " + json.dumps(list(e)), flush=True)
opts = json.load(open(os.path.join(work, "options.json")))
stored = {"options": copy.deepcopy(opts)}
schema = D.schema_ui(D.CONFIG)
translations = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
# --- the fake Supervisor and Home Assistant ---------------------------
async def h_probe(_r):
event("GET", "/core/api/")
return aioweb.Response(text="API running.")
async def h_states(_r):
event("GET", "/core/api/states")
return aioweb.json_response(STATES)
async def h_state(r):
eid = r.match_info["eid"]
event("GET", "/core/api/states/" + eid)
found = next((s for s in STATES if s["entity_id"] == eid), None)
return (aioweb.json_response(found) if found
else aioweb.json_response({"message": "not found"}, status=404))
async def h_service(r):
body = await r.json()
event("SERVICE", r.match_info["domain"], r.match_info["service"], body)
return aioweb.json_response([])
async def h_mqtt(_r):
event("GET", "/services/mqtt")
return aioweb.json_response({"result": "error"}, status=400)
async def h_info(_r):
return aioweb.json_response({"data": {"slug": "local_goodwe",
"homeassistant": "2026.9.1"}})
async def h_self_info(_r):
return aioweb.json_response({"data": {
"slug": "local_goodwe", "options": stored["options"],
"schema": schema, "translations": translations}})
async def h_options(r):
body = await r.json()
stored["options"] = body["options"]
event("OPTIONS")
with open(os.path.join(work, "saved.json"), "w") as f:
json.dump(body["options"], f)
return aioweb.json_response({"result": "ok", "data": {}})
async def h_restart(_r):
event("RESTART")
return aioweb.json_response({"result": "ok", "data": {}})
async def h_other(r):
event("OTHER", r.method, r.path)
return aioweb.json_response({}, status=404)
fake = aioweb.Application()
fake.add_routes([
aioweb.get("/core/api/", h_probe),
aioweb.get("/core/api/states", h_states),
aioweb.get("/core/api/states/{eid}", h_state),
aioweb.post("/core/api/services/{domain}/{service}", h_service),
aioweb.get("/services/mqtt", h_mqtt),
aioweb.get("/info", h_info),
aioweb.get("/addons/self/info", h_self_info),
aioweb.post("/addons/self/options", h_options),
aioweb.post("/addons/self/restart", h_restart),
aioweb.route("*", "/{tail:.*}", h_other)])
fake_port = free_port()
class ToFake(AbstractResolver):
"""`http://supervisor` is the fake, on its port."""
async def resolve(self, host, port=0, family=socket.AF_INET):
return [{"hostname": host, "host": "127.0.0.1", "port": fake_port,
"family": socket.AF_INET, "proto": 0, "flags": 0}]
async def close(self):
pass
Real = aiohttp.ClientSession
def session(*a, **k):
k.setdefault("connector", aiohttp.TCPConnector(resolver=ToFake()))
return Real(*a, **k)
aiohttp.ClientSession = session
os.environ["SUPERVISOR_TOKEN"] = "test-token"
M.OPTIONS_PATH = os.path.join(work, "options.json")
app.store.STATE_PATH = os.path.join(work, "state.json")
M.INGRESS_PORT = free_port()
kw = M.Controller.__init__.__kwdefaults__
kw["peak_db_path"] = os.path.join(work, "tariff.db")
kw["price_history_path"] = os.path.join(work, "price_history.json")
served = []
real_start = app.web.start
async def start(controller, port=8099, selfcfg=None):
runner = await real_start(controller, port, selfcfg)
served.append(type(controller).__name__) # listening now
event("SERVED", type(controller).__name__,
getattr(controller.hass, "read_only", None))
return runner
app.web.start = start
real_amain = M.amain
root = f"http://127.0.0.1:{M.INGRESS_PORT}/"
async def ask(s, method, path, body=None):
async with s.request(method, root + path, json=body) as r:
try:
out = await r.json(content_type=None)
except ValueError:
out = None
event("PAGE", method, path, r.status, out)
return r.status, out
async def scenario_steps():
async with Real() as s:
await ask(s, "GET", "status")
_st, model = await ask(s, "GET", "config/model?lang=en")
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
await ask(s, "POST", "action", {"action": "auto_toggle"})
if scenario.startswith("setup"):
await ask(s, "POST", "action", {"action": "maint_start"})
wiz = B.apply(model["ui"], B.BASE)
wiz = B.browser(json.loads(json.dumps(wiz)))
await ask(s, "POST", "config/validate",
{"model": wiz, "setup": True})
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
await ask(s, "POST", "config/save",
{"model": wiz, "base": model["ui"],
"fingerprint": model["fingerprint"],
"setup": "done"})
await ask(s, "POST", "config/restart", {"confirm": "restart"})
await asyncio.sleep(3.0) # room for any stray task to write
await ask(s, "GET", "status")
async def wrapped():
runner = aioweb.AppRunner(fake)
await runner.setup()
await aioweb.TCPSite(runner, "127.0.0.1", fake_port).start()
task = asyncio.create_task(real_amain())
try:
t0 = time.monotonic()
while not served and not task.done() \
and time.monotonic() - t0 < 20:
await asyncio.sleep(0.05)
if task.done():
event("ENDED_BEFORE_SERVING")
await task
return
await scenario_steps()
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
await asyncio.wait_for(task, 20)
finally:
await runner.cleanup()
M.amain = wrapped
M.main()
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def run_child(scenario, options, state=None):
work = tempfile.mkdtemp(prefix="ux15-")
with open(os.path.join(work, "options.json"), "w") as f:
json.dump(options, f)
if state is not None:
with open(os.path.join(work, "state.json"), "w") as f:
json.dump(state, f)
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
try:
p = subprocess.run([sys.executable, "-B", __file__, "--driver",
scenario, work], cwd=HERE, env=env,
capture_output=True, text=True, timeout=DEADLINE_S)
rc, out, err = p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired as e:
rc, out, err = None, e.stdout or "", e.stderr or ""
out = out.decode() if isinstance(out, bytes) else out
err = err.decode() if isinstance(err, bytes) else err
ev = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
files = {}
for name in ("saved.json", "state.json"):
path = os.path.join(work, name)
if os.path.exists(path):
files[name] = json.load(open(path))
shutil.rmtree(work, ignore_errors=True)
return rc, ev, err, files
def page(ev, method, path):
"""(status, body) of each page request, in order."""
return [(e[3], e[4]) for e in ev
if e[0] == "PAGE" and e[1] == method and e[2].startswith(path)]
def tail(text, n=12):
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
if len(sys.argv) == 4 and sys.argv[1] == "--driver":
driver(sys.argv[2], sys.argv[3])
sys.exit(0)
# --------------------------------------------------------------------------- #
print("1. when setup-only mode applies")
# --------------------------------------------------------------------------- #
from app import setuponly # noqa: E402
from app.validate import validate_startup_config # noqa: E402
SHIPPED = defaults()
V = validate_startup_config
check("the shipped defaults are refused only for the missing power request "
"entity, and that is setup-only mode",
setuponly.applies(SHIPPED, V(SHIPPED)) and len(V(SHIPPED)) == 1)
_blank_bad = dict(SHIPPED, maintenance_charge_w=2500)
check("no power request entity AND another refusal: not setup-only, exit 78",
len(V(_blank_bad)) >= 2 and not setuponly.applies(_blank_bad,
V(_blank_bad)))
_complete_bad = dict(SHIPPED, setpoint_entity="number.inv1_power",
maintenance_charge_w=2500)
check("a complete configuration that is refused: not setup-only, exit 78",
V(_complete_bad) and not setuponly.applies(_complete_bad,
V(_complete_bad)))
_list_blank = dict(SHIPPED, inverters=[{
"name": "inverter-a", "setpoint_entity": "", "soc_entity": "sensor.s",
"batt_entity": "sensor.b", "max_w": 2000, "bms_soc_floor": 10}])
check("an inverter list whose entry has no power request entity: not "
"setup-only (someone edited it), exit 78",
V(_list_blank) and not setuponly.applies(_list_blank, V(_list_blank)))
_ok = dict(SHIPPED, setpoint_entity="number.inv1_power")
check("a startable configuration: nothing refused, the normal start",
V(_ok) == [] and not setuponly.applies(_ok, V(_ok)))
# --------------------------------------------------------------------------- #
print("\n2. in setup-only mode the write path refuses")
# --------------------------------------------------------------------------- #
from app import hass as H # noqa: E402
import app.main as M # noqa: E402
class Session:
"""Records every request a HomeAssistant would send."""
def __init__(self):
self.sent = []
def post(self, url, **k):
self.sent.append(("POST", url))
raise AssertionError("a request was sent")
def get(self, url, **k):
self.sent.append(("GET", url))
raise AssertionError("a request was sent")
def _refused(coro):
"""True only for the read-only refusal; a request that reached the
session (AssertionError there) or no refusal at all is False."""
try:
asyncio.run(coro)
except H.ReadOnly:
return True
except Exception: # noqa: BLE001
return False
return False
_s = Session()
_ro = H.HomeAssistant(_s, "t", read_only=True)
check("a read-only HomeAssistant refuses number.set_value before it sends "
"anything", _refused(_ro.set_number("number.inv1_power", 0.0))
and _s.sent == [])
check("...and any other service call, e.g. a notification",
_refused(_ro.call("persistent_notification", "create",
{"message": "x"})) and _s.sent == [])
try:
_ro.read_only = False
_flipped = True
except AttributeError:
_flipped = False
check("...and read-only cannot be switched off afterwards",
not _flipped and _ro.read_only is True)
_rw = H.HomeAssistant(Session(), "t")
try:
asyncio.run(_rw.set_number("number.inv1_power", 0.0))
_sent = False
except AssertionError:
_sent = True
check("control: a normal HomeAssistant does send the write", _sent
and _rw.read_only is False)
try:
M.Controller(dict(_ok), _ro, None, None, stop=asyncio.Event())
_built = True
except ValueError as err:
_built = "read-only" not in str(err)
except Exception: # noqa: BLE001
_built = True # it got past the check and failed on something else
check("no controller can be built on a read-only HomeAssistant", not _built)
try:
setuponly.SetupOnly(SHIPPED, _rw, None)
_took = True
except ValueError:
_took = False
check("setup-only mode refuses a HomeAssistant that can write", not _took)
_so = setuponly.SetupOnly(SHIPPED, _ro, None)
check("setup-only mode holds no inverter and never says control is started",
list(_so.inverters) == [] and _so.auto is False
and _so.status_payload()["setup_only"] is True)
# --------------------------------------------------------------------------- #
print("\n3. the real main(): the shipped defaults serve the wizard, 0 writes")
# --------------------------------------------------------------------------- #
_runs = {
"setup": (dict(SHIPPED), None),
# A started-control attempt three ways: auto_start on, a Store that says
# control was started, and the page's own Start (the driver presses it).
"setup_started": (dict(SHIPPED, auto_start=True), {"auto": True}),
}
SAVED = None
for name, (options, st) in _runs.items():
rc, ev, err, files = run_child(name, options, st)
services = [e for e in ev if e[0] == "SERVICE"]
print(f" {name}: exit {rc}, {len(ev)} requests logged, "
f"{len(services)} service calls")
check(f"{name}: the process served the page in setup-only mode on a "
"read-only HomeAssistant, and exited 0 when stopped",
rc == 0 and ["SERVED", "SetupOnly", True] in ev)
if rc != 0:
print(tail(err))
check(f"{name}: 0 service calls to Home Assistant (no set_value, no "
"notification), and MQTT was never asked for",
services == [] and ["GET", "/services/mqtt"] not in ev)
_status = page(ev, "GET", "status")
check(f"{name}: /status says setup_only, with the setup banner",
_status and all(b and b.get("setup_only") is True
and b.get("mode") == "setup_only"
and b.get("banner", "").startswith("Setup")
for _c, b in _status))
_model = page(ev, "GET", "config/model")
check(f"{name}: the page model opens the wizard (no power request) and "
"says setup-only",
_model and _model[0][1]["setup_needed"] == "no_power_request"
and _model[0][1]["setup_only"] is True)
_acts = page(ev, "POST", "action")
check(f"{name}: Start and the battery cycle are refused with 409",
len(_acts) == 2 and all(c == 409 for c, _b in _acts))
check(f"{name}: setup-only mode writes no Store file before the wizard "
"saves, so the first-run rule still sees a new install",
[e for e in ev if e[0] == "STATE_FILE"][:2]
== [["STATE_FILE", st is not None]] * 2)
_val = page(ev, "POST", "config/validate")
check(f"{name}: the wizard's live checks run and find nothing red",
_val and _val[0][0] == 200
and not [m for m in _val[0][1]["messages"] if m["tier"] == "bad"]
and not [m for m in _val[0][1]["setup"] if m["tier"] == "bad"])
_save = page(ev, "POST", "config/save")
check(f"{name}: the wizard saves (200) and asks for the restart",
_save and _save[0][0] == 200 and ["RESTART"] in ev
and "saved.json" in files)
check(f"{name}: the Store gets setup_done from the save",
bool(files.get("state.json", {}).get("setup_done")))
if name == "setup":
SAVED, SAVED_STATE = files.get("saved.json"), files.get("state.json")
check("the saved blob passes the normal gate: no refusal, not setup-only",
SAVED is not None and V(SAVED) == []
and not setuponly.applies(SAVED, V(SAVED))
and SAVED.get("shadow_mode") == "on")
print("\n after the restart, on the saved settings:")
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
services = [e for e in ev if e[0] == "SERVICE"]
writes = [e[3].get("value") for e in services
if e[1:3] == ["number", "set_value"]]
print(f" after: exit {rc}, {len(services)} service calls, set_value "
f"values {sorted(set(writes))}")
check("the next boot passes the normal gate: a Controller serves the page, "
"and it exits 0 when stopped", rc == 0 and ["SERVED", "Controller",
False] in ev)
if rc != 0:
print(tail(err))
check("...where Start is accepted (200), unlike in setup-only mode",
[c for c, _b in page(ev, "POST", "action")] == [200])
_status = page(ev, "GET", "status")
check("...its /status is not setup-only", _status and all(
b and "setup_only" not in b for _c, b in _status))
_model = page(ev, "GET", "config/model")
check("...and the wizard does not open again: setup_done is in the Store",
_model and _model[0][1]["setup_needed"] is None
and _model[0][1]["setup_only"] is False)
check("...and it runs in shadow, even after Start: every service call is "
"number.set_value 0 W", writes and len(writes) == len(services)
and all(w == 0.0 for w in writes))
# --------------------------------------------------------------------------- #
print("\n4. a refused configuration that is not only missing setup: exit 78")
# --------------------------------------------------------------------------- #
for name, options in (("refused", _complete_bad), ("refused_blank", _blank_bad)):
rc, ev, err, _files = run_child(name, options)
print(f" {name}: exit {rc}")
check(f"{name}: exit 78 and nothing served, no service call",
rc == 78 and not [e for e in ev if e[0] in ("SERVED", "SERVICE")]
and ["ENDED_BEFORE_SERVING"] in ev)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+30 -2
View File
@@ -11,6 +11,12 @@ P2: the fixture Supervisor serves config.yaml's defaults with this site's
options, a schema read off config.yaml the way the Supervisor renders it
(`schema_ui()`) and translations/en.yaml, and keeps what the settings view
saves in memory, so the Installer view can be clicked through end to end.
P4: STATE `new` is a new install (config.yaml's defaults with one power
request entity, an empty Store made at this boot, control never started), so the page opens
the setup wizard by itself. A one-entity read of sensor.grid steps through
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
reversed meter (down).
"""
import asyncio
@@ -39,6 +45,8 @@ PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
class Store(dict):
fresh = False # app/store.py: no state file at this boot
def set(self, k, v):
self[k] = v
@@ -74,8 +82,12 @@ class Hass:
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def state(self, e):
s = next((dict(x) for x in STATES if x["entity_id"] == e), None)
if s and e == "sensor.grid":
n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1
s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)])
return s
async def set_number(self, _e, _v):
return True
@@ -87,6 +99,10 @@ class Hass:
return STATES
KETTLE_READS = (-10, 1990, 1990, -10)
KETTLE_COUNT = [0]
def _state(eid, name, state, unit=None, cls=None):
return {"entity_id": eid, "state": state,
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
@@ -240,6 +256,18 @@ def controller(state):
async def main(state, port, host="127.0.0.1"):
if state == "new":
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
store = Store()
store.fresh = True # a new /data: the wizard opens by itself
c = Controller(dict(stored), Hass(), store, Pub(),
stop=asyncio.Event())
c.auto = False
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving new on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
c = controller(state)
stored = dict(defaults(CONFIG), **OPTS)
stored.update(setpoint_entity="", meter_source="ha_signed",
@@ -0,0 +1,255 @@
"""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 as shipped, one with a power request entity, and 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 as shipped
(config.yaml's defaults, served in setup-only mode since UX-15), one with a
power request entity typed in the Configuration tab, 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 = [("empty", D.defaults(D.CONFIG)), ("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__)