"""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.` for one inverter in the list, or `ui.` 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.` 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", )` 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.` 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), # OPT-11 P4: the solar forecast; today's also on Settings, Simple. Field("pv_forecast_entity", ENTITY, kind="energy", simple=True, note="forecast"), Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"), # OPT-17: several forecast sensors per day, summed. Field("pv_forecast_entities", ENTITY_LIST, kind="energy"), Field("pv_forecast_tomorrow_entities", ENTITY_LIST, kind="energy"), Field("solar_trust_pct", SLIDER, "%", step=5), )), )), 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_margin_w", SLIDER, "W", step=10), # SAFETY-79 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"]