UX-13 P4: the setup wizard for installers
The Setup tab draws the Installer view's page model one step at a time, from each field's `wizard` step in app/uilayout.py: meter (with the kettle test), inverters and batteries, solar (optional), prices, peak limit (battery modes: "comes in a later update", OPT-11), check and start. It reuses P2's controls and saves through /config/validate and /config/save. No route was added. - First run: /config/model says `setup_needed` when the stored options name no power request entity, or on a new install (no `setup_done` and no `auto` in the Store, control not started). The page opens #setup only when opened with no route. The wizard's save writes `setup_done`. - Kettle test: GET ha/entities?entity= reads one entity now, in W. The change decides: up keeps meter_invert off, down turns it on; a net or import/export meter that goes down blocks the step with what to check. - Last step: validate.setup_checks() beside the refusals: the meter reads, each power request entity exists in HA (bad if not, SAFETY-77), battery levels and power with direction, solar sign, prices. Saves with shadow_mode on by default, then a confirmed restart naming the 0 W. - P2 test finding (a): a stale save lists only the keys the other surface moved (the blob handed out on open, by fingerprint, against stored now). - test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard can save; all accepted by Supervisor 2026.08.0 AppOptions (offline). - DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -11,6 +11,12 @@ P2: the fixture Supervisor serves config.yaml's defaults with this site's
|
||||
options, a schema read off config.yaml the way the Supervisor renders it
|
||||
(`schema_ui()`) and translations/en.yaml, and keeps what the settings view
|
||||
saves in memory, so the Installer view can be clicked through end to end.
|
||||
|
||||
P4: STATE `new` is a new install (config.yaml's defaults with one power
|
||||
request entity, an empty Store, control never started), so the page opens
|
||||
the setup wizard by itself. A one-entity read of sensor.grid steps through
|
||||
KETTLE_READS, so the kettle test sees the kettle go on (up) and then a
|
||||
reversed meter (down).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -74,8 +80,12 @@ class Hass:
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
async def state(self, e):
|
||||
s = next((dict(x) for x in STATES if x["entity_id"] == e), None)
|
||||
if s and e == "sensor.grid":
|
||||
n = KETTLE_COUNT[0] = KETTLE_COUNT[0] + 1
|
||||
s["state"] = str(KETTLE_READS[(n - 1) % len(KETTLE_READS)])
|
||||
return s
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
@@ -87,6 +97,10 @@ class Hass:
|
||||
return STATES
|
||||
|
||||
|
||||
KETTLE_READS = (-10, 1990, 1990, -10)
|
||||
KETTLE_COUNT = [0]
|
||||
|
||||
|
||||
def _state(eid, name, state, unit=None, cls=None):
|
||||
return {"entity_id": eid, "state": state,
|
||||
"attributes": {"friendly_name": name, "unit_of_measurement": unit,
|
||||
@@ -240,6 +254,16 @@ def controller(state):
|
||||
|
||||
|
||||
async def main(state, port, host="127.0.0.1"):
|
||||
if state == "new":
|
||||
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
|
||||
c = Controller(dict(stored), Hass(), Store(), Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = False
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
await runner.setup()
|
||||
await aioweb.TCPSite(runner, host, port).start()
|
||||
print(f"serving new on http://127.0.0.1:{port}/", flush=True)
|
||||
await asyncio.Event().wait()
|
||||
c = controller(state)
|
||||
stored = dict(defaults(CONFIG), **OPTS)
|
||||
stored.update(setpoint_entity="", meter_source="ha_signed",
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""UX-13 P4: every options blob the setup wizard can save, and the Supervisor's
|
||||
own validator run over them.
|
||||
|
||||
python3 tools/ux13_wizard_blobs.py --dump DIR # write the blobs
|
||||
docker run --rm --network none -v "$PWD:/w:ro" -v DIR:/blobs:ro \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/w/tools/ux13_wizard_blobs.py --supervisor /blobs
|
||||
|
||||
The wizard edits the page model one step at a time and saves it through
|
||||
uipage.to_options(), like the Installer view. `blobs()` walks each step's
|
||||
choices (meter source and sign, one to three inverters, solar rows, every
|
||||
contract and price source, region, peak limit, shadow mode) from a new
|
||||
install and from the TEST-23 sites, and keeps what the shipped validator
|
||||
accepts, because the wizard saves nothing it refuses. test_setup.py checks
|
||||
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
|
||||
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def browser(value):
|
||||
"""What JSON.parse then JSON.stringify does to a value: 3.0 becomes 3."""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
if isinstance(value, list):
|
||||
return [browser(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: browser(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
# --- the wizard's steps, as edits to the page model ------------------------ #
|
||||
METERS = {
|
||||
"entity": {"meter_source": "off", "meter_entity": "sensor.grid"},
|
||||
"entity_flipped": {"meter_source": "off", "meter_entity": "sensor.grid",
|
||||
"meter_invert": True},
|
||||
"signed": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net"},
|
||||
"signed_3": {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 3, "p1_phase_net_entities": [
|
||||
"sensor.l1", "sensor.l2", "sensor.l3"]},
|
||||
"dsmr": {"meter_source": "ha_dsmr", "p1_import_entity": "sensor.imp",
|
||||
"p1_export_entity": "sensor.exp"},
|
||||
"homewizard": {"meter_source": "homewizard_local",
|
||||
"p1_host": "192.0.2.50"},
|
||||
"mqtt": {"meter_source": "mqtt_p1", "meter_mqtt_topic": "p1/power"},
|
||||
}
|
||||
|
||||
|
||||
def _card(i, flat=False, **more):
|
||||
card = {"setpoint_entity": f"number.inv{i}_power",
|
||||
"soc_entity": f"sensor.inv{i}_level",
|
||||
"batt_entity": f"sensor.inv{i}_battery", "batt_invert": False,
|
||||
"max_w": 2500, "bms_soc_floor": 10}
|
||||
if flat:
|
||||
card.update(name="", capacity_kwh=None)
|
||||
else:
|
||||
card.update(name=f"inverter-{i}", capacity_kwh=5.0)
|
||||
card.update(more)
|
||||
return card
|
||||
|
||||
|
||||
ROSTERS = {
|
||||
"one": ("flat", [_card(1, flat=True)]),
|
||||
"one_flipped": ("flat", [_card(1, flat=True, batt_invert=True,
|
||||
max_w=3000)]),
|
||||
"two": ("list", [_card(1), _card(2, max_w=4600, bms_soc_floor=15)]),
|
||||
"three": ("list", [_card(1), _card(2), _card(3, capacity_kwh=9.6)]),
|
||||
}
|
||||
SOLAR = {
|
||||
"none": [],
|
||||
"one": [{"entity": "sensor.pv", "reversed": False}],
|
||||
"one_reversed": [{"entity": "sensor.pv", "reversed": True}],
|
||||
"two": [{"entity": "sensor.pv_a", "reversed": False},
|
||||
{"entity": "sensor.pv_b", "reversed": True}],
|
||||
}
|
||||
PRICES = {
|
||||
"none": {"ui.contract": "none", "ui.fallback": "none"},
|
||||
"fixed": {"ui.contract": "fixed", "ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.31},
|
||||
"daynight": {"ui.contract": "daynight", "ui.fallback": "daynight",
|
||||
"ui.day_price": 0.34, "ui.night_price": 0.25},
|
||||
"dynamic": {"ui.contract": "dynamic", "price_provider": "generic_dayahead",
|
||||
"ui.fallback": "none"},
|
||||
"dynamic_fixed": {"ui.contract": "dynamic",
|
||||
"price_provider": "generic_dayahead",
|
||||
"ui.fallback": "fixed", "ui.fixed_price": 0.3},
|
||||
"dynamic_daynight": {"ui.contract": "dynamic",
|
||||
"price_provider": "generic_dayahead",
|
||||
"ui.fallback": "daynight", "ui.day_price": 0.3,
|
||||
"ui.night_price": 0.2},
|
||||
"formula": {"ui.contract": "dynamic", "price_provider": "custom_formula",
|
||||
"price_formula": "<the documented example>",
|
||||
"ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.3},
|
||||
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
|
||||
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.3},
|
||||
}
|
||||
REGIONS = {"flanders": {"ui.region": "flanders"},
|
||||
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
|
||||
PEAKS = {"low": {"peak_cap_w": 2500, "peak_forecast_entity": ""},
|
||||
"forecast": {"peak_cap_w": 4200,
|
||||
"peak_forecast_entity": "sensor.peak_forecast"},
|
||||
"high": {"peak_cap_w": 10000, "peak_forecast_entity": ""}}
|
||||
SHADOWS = ("on", "auto") # the switch on, or off (uilayout.SHADOW)
|
||||
BASE = {"meter": "entity", "roster": "one", "solar": "none",
|
||||
"prices": "none", "region": "own", "peak": "low", "shadow": "on"}
|
||||
|
||||
|
||||
def plans():
|
||||
"""Each step's choices once, with the other steps at BASE, then a few
|
||||
whole sites that combine them."""
|
||||
axes = {"meter": METERS, "roster": ROSTERS, "solar": SOLAR,
|
||||
"prices": PRICES, "region": REGIONS, "peak": PEAKS,
|
||||
"shadow": dict.fromkeys(SHADOWS)}
|
||||
seen = []
|
||||
for axis, choices in axes.items():
|
||||
for choice in choices:
|
||||
plan = dict(BASE, **{axis: choice})
|
||||
if plan not in seen:
|
||||
seen.append(plan)
|
||||
for plan in (
|
||||
{"meter": "dsmr", "roster": "two", "solar": "two",
|
||||
"prices": "dynamic_daynight", "region": "flanders",
|
||||
"peak": "forecast", "shadow": "on"},
|
||||
{"meter": "signed_3", "roster": "three", "solar": "one_reversed",
|
||||
"prices": "daynight", "region": "flanders", "peak": "high",
|
||||
"shadow": "auto"},
|
||||
{"meter": "homewizard", "roster": "one_flipped", "solar": "one",
|
||||
"prices": "fixed", "region": "own", "peak": "forecast",
|
||||
"shadow": "on"},
|
||||
{"meter": "mqtt", "roster": "two", "solar": "none",
|
||||
"prices": "formula", "region": "flanders", "peak": "low",
|
||||
"shadow": "on"}):
|
||||
seen.append(plan)
|
||||
return seen
|
||||
|
||||
|
||||
def apply(model, plan):
|
||||
"""The page model after the wizard's six steps, as the page leaves it."""
|
||||
from app import uilayout as L, uimodel as U
|
||||
m = copy.deepcopy(model)
|
||||
m.update(copy.deepcopy(METERS[plan["meter"]]))
|
||||
shape, cards = ROSTERS[plan["roster"]]
|
||||
cards = copy.deepcopy(cards)
|
||||
if m.get("inverter_shape") == "list":
|
||||
# A site stored as a list stays one: one card keeps a name.
|
||||
for i, card in enumerate(cards, 1):
|
||||
card.update(name=card["name"] or f"inverter-{i}",
|
||||
capacity_kwh=card["capacity_kwh"] or 5.0)
|
||||
m["inverters"] = cards
|
||||
m[U.COUNT] = len(cards)
|
||||
if shape == "list":
|
||||
m.update(U.GROWN)
|
||||
m["ui.solar"] = copy.deepcopy(SOLAR[plan["solar"]])
|
||||
m.update(copy.deepcopy(PRICES[plan["prices"]]))
|
||||
if plan["prices"] == "formula":
|
||||
from app.custom_formula import _LEGS
|
||||
m["price_formula"] = _LEGS["price_formula"].example
|
||||
m.update(copy.deepcopy(REGIONS[plan["region"]]))
|
||||
if plan["region"] != "own":
|
||||
m["price_taxes_eur_kwh"] = U.REGION_PRESETS[plan["region"]][
|
||||
"taxes_eur_kwh"]
|
||||
m.update(PEAKS[plan["peak"]])
|
||||
field, on, off = L.SHADOW
|
||||
m[field] = on if plan["shadow"] == "on" else (
|
||||
m.get(field) if m.get(field) != on else off)
|
||||
return m
|
||||
|
||||
|
||||
def starts():
|
||||
"""(name, stored options) the wizard opens on: a new install (config.yaml's
|
||||
defaults and one power request entity, typed in the Configuration tab so
|
||||
the add-on starts) and the TEST-23 sites."""
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import ux13_demo as D
|
||||
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
|
||||
out = [("new", new)]
|
||||
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
|
||||
"*.json")))[:3]:
|
||||
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
|
||||
**json.load(open(f)))))
|
||||
return out
|
||||
|
||||
|
||||
def blobs():
|
||||
"""[(name, blob, refusals)] for every plan from every start."""
|
||||
sys.path.insert(0, HERE)
|
||||
from app import uimodel as U, uipage
|
||||
from app.validate import validate_startup_config
|
||||
out = []
|
||||
for start, stored in starts():
|
||||
model = json.loads(json.dumps(U.options_to_ui(stored)))
|
||||
for plan in plans():
|
||||
name = start + "-" + "-".join(f"{k}.{v}" for k, v in plan.items())
|
||||
posted = browser(json.loads(json.dumps(apply(model, plan))))
|
||||
blob = uipage.to_options(posted, stored)
|
||||
out.append((name, blob, validate_startup_config(blob)))
|
||||
return out
|
||||
|
||||
|
||||
# --- run inside the Supervisor image --------------------------------------- #
|
||||
def supervisor(folder):
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
import voluptuous as vol
|
||||
from pathlib import Path
|
||||
from supervisor.apps.options import AppOptions
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
cfg = SCHEMA_APP_CONFIG(read_yaml_file(Path(HERE) / "config.yaml"))
|
||||
check = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, cfg["schema"], cfg["name"], "local_goodwe_controller")))
|
||||
bad = 0
|
||||
files = sorted(glob.glob(os.path.join(folder, "*.json")))
|
||||
for f in files:
|
||||
blob = json.load(open(f))
|
||||
try:
|
||||
got = check(copy.deepcopy(blob))
|
||||
dropped = sorted(set(blob) - set(got))
|
||||
ok = not dropped
|
||||
why = f"dropped {dropped}" if dropped else f"{len(got)} keys"
|
||||
except vol.Invalid as err:
|
||||
ok, why = False, str(err)
|
||||
bad += not ok
|
||||
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(f)}: {why}")
|
||||
print(f"{len(files) - bad} of {len(files)} accepted by AppOptions "
|
||||
f"({cfg['version']})")
|
||||
return 1 if bad or not files else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:2] == ["--supervisor"]:
|
||||
sys.exit(supervisor(sys.argv[2]))
|
||||
if sys.argv[1:2] == ["--dump"]:
|
||||
os.makedirs(sys.argv[2], exist_ok=True)
|
||||
n = 0
|
||||
for name, blob, refused in blobs():
|
||||
if refused:
|
||||
print("refused, not written:", name, refused[0])
|
||||
continue
|
||||
with open(os.path.join(sys.argv[2], name + ".json"), "w") as f:
|
||||
json.dump(blob, f, indent=1)
|
||||
n += 1
|
||||
print(f"{n} blobs written to {sys.argv[2]}")
|
||||
sys.exit(0)
|
||||
print(__doc__)
|
||||
Reference in New Issue
Block a user