Files
goodwe-addon/goodwe_controller/app/uilayout.py
T
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- 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
2026-09-28 02:49:58 +02:00

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"]