- Batteries: one card per battery with On/Off, the mode list (cheap hours and selling greyed out until OPT-11 P3), level, and Now / Today / Why lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses the existing pack_use / pack_mode actions and store keys. - An Off battery the peak limit still needs says so with its figure; no line shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned yet" until OPT-11 P3/P4. - Home's plan leaves Off batteries out, except for the peak limit. - Settings opens on Simple: the layout's simple fields with an applies chip and a note, plus the battery switches and modes. Installer is one tap. - English and Dutch words in app/ui_text; test_batteries_screen.py; browser check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and changed screenshots only. No control, allocator, arbiter or command module changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
340 lines
15 KiB
Python
340 lines
15 KiB
Python
"""UX-13: the new page's layout, written once - screen, group, field.
|
|
|
|
UI code names option keys only here and in uimodel.py, which holds the
|
|
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
|
|
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
|
|
schema, rules in validate.py. test_uilayout.py fails CI when an option in
|
|
config.yaml has no place here.
|
|
|
|
A field's `key` is an option key, `inverters.<field>` for one inverter in the
|
|
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
|
|
The key is also its text key: label and help are translations/*.yaml's entry
|
|
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
|
|
NOW = "now" # runtime state (OPT-11's battery switches), none yet
|
|
|
|
# Controls are names, not string literals, so a key is never followed by a
|
|
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
|
|
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
|
|
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
|
|
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
|
|
"number", "text", "select", "locked", "inverter_list", "solar_list")
|
|
PERIOD_LIST = "period_list" # P5: time windows, each with its own price
|
|
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
|
|
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_LIST)
|
|
# What an entity picker offers first. `power_w` is a power sensor the add-on
|
|
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
|
|
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
|
|
SHOW_OPS = ("==", "!=", "in", ">", "<")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Field:
|
|
key: str
|
|
control: str
|
|
unit: str = ""
|
|
scale: int = 1 # shown = stored / scale (the peak limit in kW)
|
|
step: float = 0 # slider or number step, stored units; 0 = 1
|
|
kind: str = "" # entity picker filter, one of ENTITY_KINDS
|
|
show: tuple = () # ((name, op, value), ...), all must hold
|
|
applies: str = RESTART
|
|
simple: bool = False # also on Settings, Simple
|
|
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
|
|
mirror: str = "" # a choice also copied to this field, if offered
|
|
fills: str = "" # a preset choice fills this field (uimodel)
|
|
note: str = "" # P3: a line under it on Settings, Simple
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
"""Where the label and help live: `translations` or `ui_text`."""
|
|
return "ui_text" if self.key.startswith("ui.") else "translations"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Part:
|
|
id: str # "" is the group's main part
|
|
fields: tuple
|
|
collapsed: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Group:
|
|
id: str
|
|
parts: tuple
|
|
support_only: bool = False # collapsed, with a "for support" warning
|
|
|
|
|
|
def _src(*values):
|
|
return (("meter_source", "in", values),)
|
|
|
|
|
|
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
|
|
_MAINT = (("maintenance_enabled", "==", True),)
|
|
_ONE = (("inverter_count", "==", 1),)
|
|
_SEVERAL = (("inverter_count", ">", 1),)
|
|
_PHASES = (("meter_phases", ">", 1),)
|
|
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
|
|
_LISTED = (("inverter_shape", "!=", "flat"),)
|
|
|
|
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
|
|
ROSTER_FIELDS = (
|
|
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
|
|
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
|
|
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
|
|
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
|
|
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
|
|
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
|
|
Field("inverters.batt_invert", TOGGLE, wizard=2),
|
|
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
|
|
wizard=2),
|
|
)
|
|
|
|
GROUPS = (
|
|
Group("meter", (
|
|
Part("", (
|
|
Field("meter_source", CHOICE, wizard=1),
|
|
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
|
|
wizard=1),
|
|
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
|
|
Field("p1_net_entity", ENTITY, kind="power_w",
|
|
show=_src("ha_signed"), wizard=1),
|
|
Field("p1_import_entity", ENTITY, kind="power_w",
|
|
show=_src("ha_dsmr"), wizard=1),
|
|
Field("p1_export_entity", ENTITY, kind="power_w",
|
|
show=_src("ha_dsmr"), wizard=1),
|
|
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
|
|
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
|
|
Field("meter_phases", SEGMENTED, wizard=1),
|
|
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
|
|
show=_src("ha_signed") + _PHASES, wizard=1),
|
|
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
|
|
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
|
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
|
|
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
|
)),
|
|
Part("timing", (
|
|
Field("meter_max_age_s", SLIDER, "s", step=5),
|
|
Field("meter_poll_s", SLIDER, "s",
|
|
show=_src("homewizard_local")),
|
|
), collapsed=True),
|
|
)),
|
|
Group("inverters", (
|
|
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
|
|
# pv.py reads each entity's own unit, so a kW sensor is fine here.
|
|
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
|
wizard=3),)),
|
|
# OPT-11: the switch that turns battery modes on. Each battery's mode
|
|
# and on/off switch are runtime state on the Batteries screen.
|
|
Part("modes", (Field("battery_modes_enabled", TOGGLE),)),
|
|
)),
|
|
Group("tariff", (
|
|
Part("", (
|
|
# A contract without a feed is also the price used when a feed
|
|
# is down, so the fallback follows it (uimodel._tariff_encode).
|
|
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
|
|
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
|
|
Field("price_formula", TEXT,
|
|
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
|
|
wizard=4),
|
|
Field("price_csv_path", TEXT,
|
|
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
|
|
wizard=4),
|
|
Field("ui.region", SELECT, wizard=4,
|
|
fills="price_taxes_eur_kwh"),
|
|
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
|
|
wizard=4),
|
|
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
|
show=_DYNAMIC, wizard=4),
|
|
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
|
wizard=4),
|
|
Field("price_export_formula", TEXT),
|
|
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
|
|
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
|
|
show=(("ui.fallback", "==", "fixed"),), wizard=4),
|
|
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
|
|
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
|
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
|
|
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
|
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
|
|
show=(("ui.fallback", "==", "custom"),), wizard=4),
|
|
)),
|
|
Part("selling", (
|
|
# OPT-11: derived from the batteries' selling switches once battery
|
|
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
|
|
Field("arbitrage_enabled", TOGGLE),
|
|
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
|
|
Field("arbitrage_soc_floor", SLIDER, "%"),
|
|
Field("arbitrage_soc_ceiling", SLIDER, "%"),
|
|
)),
|
|
Part("feed", (
|
|
Field("price_feed_url", TEXT, show=_DYNAMIC),
|
|
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
|
|
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
|
|
Field("price_now_entity", ENTITY, kind="any"),
|
|
Field("price_avg_entity", ENTITY, kind="any"),
|
|
), collapsed=True),
|
|
)),
|
|
Group("safety", (
|
|
Part("peak", (
|
|
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
|
simple=True, wizard=5, note="month_peak"),
|
|
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
|
Field("peak_soc_floor", LOCKED, "%"),
|
|
)),
|
|
Part("start", (
|
|
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
|
|
note="shadow"),
|
|
Field("auto_start", TOGGLE),
|
|
Field("estop_fitted", TOGGLE),
|
|
Field("stale_input_s", SLIDER, "s"),
|
|
Field("meter_freeze_s", SLIDER, "s", step=30,
|
|
show=_src("homewizard_local")),
|
|
)),
|
|
Part("stops", (
|
|
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
|
|
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
|
|
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
|
|
show=_SEVERAL),
|
|
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
|
|
show=_SEVERAL),
|
|
Field("site_sign_disagree_w", LOCKED, "W", step=10,
|
|
show=_SEVERAL),
|
|
Field("site_sign_disagree_s", LOCKED, "s", step=5,
|
|
show=_SEVERAL),
|
|
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
|
|
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
|
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
|
), collapsed=True),
|
|
)),
|
|
Group("maintenance", (
|
|
Part("", (
|
|
Field("maintenance_enabled", TOGGLE),
|
|
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
|
|
Field("maintenance_start_hour", SELECT, show=_MAINT),
|
|
Field("maintenance_discharge_w", SLIDER, "W", step=100,
|
|
show=_MAINT),
|
|
Field("maintenance_charge_w", SLIDER, "W", step=100,
|
|
show=_MAINT),
|
|
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
|
|
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
|
|
Field("maintenance_hold_min", SLIDER, "min", step=5,
|
|
show=_MAINT),
|
|
)),
|
|
Part("support", (Field("log_level", SELECT),)),
|
|
)),
|
|
Group("tuning", (
|
|
Part("", (
|
|
Field("gain", LOCKED, step=0.05),
|
|
Field("slew_w", LOCKED, "W", step=50),
|
|
Field("deadband_w", LOCKED, "W"),
|
|
Field("target_grid_w", LOCKED, "W"),
|
|
Field("step_w", LOCKED, "W"),
|
|
Field("saturation_w", LOCKED, "W", step=10),
|
|
Field("saturation_cycles", LOCKED),
|
|
Field("integrator_max_w", LOCKED, "W", step=100),
|
|
Field("heartbeat_s", LOCKED, "s"),
|
|
Field("cycle_floor_s", LOCKED, "s"),
|
|
)),
|
|
), 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
|
|
"soc_entity": "inverters",
|
|
"batt_entity": "inverters",
|
|
"batt_invert": "inverters",
|
|
"max_w": "inverters",
|
|
"bms_soc_floor": "inverters",
|
|
"pv_entity": "ui.solar", # one solar row, stored flat
|
|
"pv_entities": "ui.solar",
|
|
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
|
|
"static_profile": "ui.fallback", # built from the price fields
|
|
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
|
}
|
|
# Option keys the page never shows and never writes: passed through as stored.
|
|
HIDDEN: dict = {}
|
|
|
|
|
|
def fields():
|
|
"""Every field once, in page order, roster fields after `inverters`."""
|
|
for group in GROUPS:
|
|
for part in group.parts:
|
|
for field in part.fields:
|
|
yield group, part, field
|
|
if field.control == INVERTER_LIST:
|
|
for sub in ROSTER_FIELDS:
|
|
yield group, part, sub
|
|
|
|
|
|
def field(key: str) -> Field | None:
|
|
return next((f for _g, _p, f in fields() if f.key == key), None)
|
|
|
|
|
|
def visible(fld: Field, model: dict) -> bool:
|
|
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
|
|
for name, op, value in fld.show:
|
|
have = model.get(name)
|
|
if op == "==" and have != value:
|
|
return False
|
|
if op == "!=" and have == value:
|
|
return False
|
|
if op == "in" and have not in value:
|
|
return False
|
|
try:
|
|
if op == ">" and not float(have) > value:
|
|
return False
|
|
if op == "<" and not float(have) < value:
|
|
return False
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def range_of(key: str, schema_ui: list):
|
|
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
|
|
|
|
Never typed here: config.yaml is the one place a range is written. A range
|
|
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
|
|
path = key.split(".")
|
|
nodes = schema_ui or []
|
|
for step in path[:-1]:
|
|
parent = next((n for n in nodes if n.get("name") == step), None)
|
|
nodes = (parent or {}).get("schema") or []
|
|
node = next((n for n in nodes if n.get("name") == path[-1]), None)
|
|
if not node or "lengthMin" not in node or "lengthMax" not in node:
|
|
return None
|
|
return node["lengthMin"], node["lengthMax"]
|