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:
@@ -374,7 +374,8 @@ class Note(NamedTuple):
|
||||
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
||||
add-on starts. `keys` are the options it is about, so the page can show it
|
||||
beside them."""
|
||||
tier: str # "warn" or "info"
|
||||
tier: str # "warn" or "info"; the wizard's checks
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
|
||||
@@ -1749,18 +1750,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
_entity_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
@@ -1769,6 +1759,230 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
return notes
|
||||
|
||||
|
||||
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
"""A note for each entity these options read that Home Assistant does not
|
||||
have, or that reads in kW where watts are taken. A missing entity whose
|
||||
key is in `bad` is a "bad" note, which blocks the setup wizard."""
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard's last step
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Below this a battery or solar reading counts as idle for a sign check.
|
||||
IDLE_W = 50.0
|
||||
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
||||
# MQTT) is only known live when the running options read the same one.
|
||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
||||
|
||||
|
||||
def power_request_entities(opts: dict) -> list:
|
||||
"""The power request entity of each inverter these options name: the flat
|
||||
one, or each entry's. Blank ones are left out."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
values = [opts.get("setpoint_entity")]
|
||||
else:
|
||||
values = [e.get("setpoint_entity") for e in entries
|
||||
if isinstance(e, dict)]
|
||||
return [str(v).strip() for v in values
|
||||
if isinstance(v, str) and v.strip()]
|
||||
|
||||
|
||||
def _reading(entities, entity, convert=False):
|
||||
"""(number, unit) of an entity's state, or (None, state text). The number
|
||||
is as the add-on reads it: raw for the meter and battery power, which it
|
||||
takes as watts, and in W for solar (`convert`), which pv.py converts."""
|
||||
row = (entities or {}).get(entity) or {}
|
||||
unit = str(row.get("unit") or "")
|
||||
try:
|
||||
value = float(row.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None, str(row.get("state") or "nothing")
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return None, str(row.get("state"))
|
||||
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
|
||||
|
||||
|
||||
def _signed(w) -> str:
|
||||
return ("-" if w < 0 else "") + messages.watts(w)
|
||||
|
||||
|
||||
def _cards(opts: dict) -> list:
|
||||
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [("", {k: opts.get(k) for k in (
|
||||
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
|
||||
out = []
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
name = _unit_label(opts, i).strip().strip("()")
|
||||
out.append((f" for {name}" if name else "", entry))
|
||||
return out
|
||||
|
||||
|
||||
def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
meter_now_w=None, prices_known=None) -> list[Note]:
|
||||
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
|
||||
|
||||
Live checks on the proposed options: the meter reads, each power request
|
||||
entity exists in Home Assistant (a write to a missing one is answered 200,
|
||||
SAFETY-77, so only the entity list can tell), the battery levels read, and
|
||||
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
|
||||
request entity, which the wizard will not save. Reads nothing itself:
|
||||
`entities` is Home Assistant's list by id (None when it did not answer),
|
||||
`running` the options this process started with, `meter_now_w` its meter
|
||||
reading and `prices_known` how many of today's hours have a price.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is None:
|
||||
_advise(notes, "warn", (), "Home Assistant is not answering, so "
|
||||
"no entity was checked yet.")
|
||||
else:
|
||||
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
|
||||
_meter_check(notes, opts, entities, running, meter_now_w)
|
||||
for who, entry in _cards(opts):
|
||||
_card_checks(notes, who, entry, entities)
|
||||
if entities is not None:
|
||||
flipped = pv.inverted(opts)
|
||||
for entity in pv.entities(opts):
|
||||
w, _unit = _reading(entities, entity, convert=True)
|
||||
if entity not in entities or w is None:
|
||||
continue
|
||||
if w < -IDLE_W and entity not in flipped:
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so its solar "
|
||||
"production sign looks reversed.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
|
||||
def _meter_check(notes, opts, entities, running, meter_now_w) -> None:
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
if source == "off":
|
||||
parts = [(opts.get("meter_entity"), 1.0)]
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
parts = [(opts.get("p1_net_entity"), 1.0)]
|
||||
elif source == SOURCE_HA:
|
||||
parts = [(opts.get("p1_import_entity"), 1.0),
|
||||
(opts.get("p1_export_entity"), -1.0)]
|
||||
else:
|
||||
same = isinstance(running, dict) and all(
|
||||
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
||||
if same and meter_now_w is not None:
|
||||
_meter_line(notes, float(meter_now_w))
|
||||
else:
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
return
|
||||
if entities is None:
|
||||
return
|
||||
total = 0.0
|
||||
for entity, sign in parts:
|
||||
entity = str(entity or "").strip()
|
||||
if not entity or entity not in entities:
|
||||
return # a refusal or a missing-entity note says so
|
||||
w, text = _reading(entities, entity)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so the meter has no "
|
||||
"reading yet.")
|
||||
return
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
total = -total
|
||||
_meter_line(notes, total)
|
||||
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities) -> None:
|
||||
if entities is None:
|
||||
return
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so the add-on holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
return
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
|
||||
|
||||
def _price_check(notes, opts, running, prices_known) -> None:
|
||||
provider = str(opts.get("price_provider") or "off").strip()
|
||||
profile = str(opts.get("static_profile") or "").strip()
|
||||
if provider in ("", "off"):
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
Reference in New Issue
Block a user