- Settings tab: the Installer view (app/static/settings.js), drawn from
app/uilayout.py through the new app/uipage.py: search, six collapsible
groups with "n to fix / n to check / n changed" badges and an "applies
after a restart" chip, radio cards, segmented choices, sliders with units
(the peak limit in kW), entity pickers with search and the live reading,
inverter cards, solar rows, locked measured values behind "Change", and
the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
config/save; uimodel turns it into options before the shipped validator,
so nothing it refuses is saved. No route added. A value equal to the
stored one is written as stored, so a save that changed nothing writes an
identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
is 0 W; (b) the trade blocks and the price line name the feed's own points
("Lowest published price"), the hour bars say "hour average"; (c) the entity
list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
304 lines
13 KiB
Python
304 lines
13 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")
|
|
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
|
|
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_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)
|
|
|
|
@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),)),
|
|
)),
|
|
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),
|
|
)),
|
|
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),
|
|
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),
|
|
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),
|
|
)
|
|
|
|
# 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"]
|