2026-08-07 13:13:17 +02:00
|
|
|
"""UI hint registry for EOSdash configuration forms.
|
|
|
|
|
|
|
|
|
|
This module decouples UI rendering decisions from both the domain models and the
|
|
|
|
|
main ``Configuration()`` render function. Instead of a long if/elif chain that
|
|
|
|
|
maps config field paths to form factories, all those decisions live here as
|
|
|
|
|
structured ``UiHint`` entries in ``UI_HINTS``.
|
|
|
|
|
|
|
|
|
|
Typical usage in ``configuration.py``::
|
|
|
|
|
|
|
|
|
|
from akkudoktoreos.server.dash.uihints import UI_HINTS, resolve_form_factory
|
|
|
|
|
|
|
|
|
|
hint = UI_HINTS.get(config["name"])
|
|
|
|
|
if hint and hint.form == "items":
|
|
|
|
|
rows.append(ConfigItemsCard(config, hint, config_details, config_update_latest))
|
|
|
|
|
elif not config["deprecated"]:
|
|
|
|
|
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
|
|
|
|
|
rows.append(ConfigCard(..., update_form_factory))
|
|
|
|
|
|
|
|
|
|
``ConfigItemsCard`` must live in ``configuration.py`` because it depends on
|
|
|
|
|
``create_config_details`` and ``config_update_latest``. This module only
|
|
|
|
|
carries the *data* that drives it.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Any, Callable, Literal, Optional
|
|
|
|
|
|
|
|
|
|
from akkudoktoreos.server.dash.components import (
|
2026-08-23 02:26:16 +02:00
|
|
|
make_config_update_lazy_select_form,
|
2026-08-07 13:13:17 +02:00
|
|
|
make_config_update_list_form,
|
|
|
|
|
make_config_update_map_form,
|
|
|
|
|
make_config_update_time_windows_windows_form,
|
|
|
|
|
make_config_update_value_form,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Form type literals
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
UiFormType = Literal[
|
|
|
|
|
"text", # plain text input (default)
|
|
|
|
|
"select", # single-value dropdown
|
|
|
|
|
"select_list", # add/delete multi-value list
|
2026-08-23 02:26:16 +02:00
|
|
|
"select_lazy", # server-filtered select for large option sets
|
2026-08-07 13:13:17 +02:00
|
|
|
"map", # key/value pair editor
|
|
|
|
|
"time_windows", # time-window sequence editor
|
|
|
|
|
"items", # expandable list of sub-model cards
|
|
|
|
|
"map_items", # expandable map of sub-model cards
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# UiHint dataclass
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class UiHint:
|
|
|
|
|
"""Rendering hints for a single configuration field.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
form:
|
|
|
|
|
Which form widget to use. Defaults to ``"text"``.
|
|
|
|
|
|
|
|
|
|
options:
|
|
|
|
|
Static allowed values for ``"select"`` / ``"select_list"``.
|
|
|
|
|
|
|
|
|
|
options_from:
|
|
|
|
|
Dotted config-field path whose runtime value provides the
|
|
|
|
|
option list (JSON-encoded ``list[str]``). Takes precedence
|
|
|
|
|
over ``options`` when both are set.
|
|
|
|
|
|
2026-08-23 02:26:16 +02:00
|
|
|
options_source:
|
|
|
|
|
``select_lazy`` only. Key into LAZY_OPTIONS_SOURCES identifying the
|
|
|
|
|
callable that lazily returns the full (potentially huge) candidate
|
|
|
|
|
list, e.g. "pvforecast.cec_modules". Distinct from options_from, which
|
|
|
|
|
reads a small already-resolved list out of config_details.
|
|
|
|
|
|
2026-08-07 13:13:17 +02:00
|
|
|
param_from:
|
|
|
|
|
Dotted config-field path for a secondary runtime parameter.
|
|
|
|
|
Used by ``"map"`` for the *keys* dropdown.
|
|
|
|
|
|
|
|
|
|
append_none:
|
|
|
|
|
Append ``"None"`` to the resolved option list. Useful for
|
|
|
|
|
nullable single-value selects such as ``*.provider`` fields.
|
|
|
|
|
|
|
|
|
|
value_description:
|
|
|
|
|
Label for the extra numeric column in the ``"time_windows"``
|
|
|
|
|
form (e.g. ``"electricity_price_kwh [Amt/kWh]"``). When
|
|
|
|
|
``None`` no value column is rendered.
|
|
|
|
|
|
|
|
|
|
item_model:
|
|
|
|
|
*``"items"`` only.* The Pydantic model class (or instance)
|
|
|
|
|
whose fields define the per-item sub-cards, e.g.
|
|
|
|
|
``PVForecastPlaneSetting``. Set via ``_ensure_item_models()``
|
|
|
|
|
at first use to avoid circular imports.
|
|
|
|
|
|
|
|
|
|
item_path:
|
|
|
|
|
*``"items"`` only.* Dotted path that locates the list inside
|
|
|
|
|
the synthetic config dict built from the field value. Used to
|
|
|
|
|
construct the ``values_prefix`` for ``create_config_details``.
|
|
|
|
|
|
|
|
|
|
Example: planes are wrapped as
|
|
|
|
|
``{"pvforecast": {"planes": <value>}}`` so ``item_path`` is
|
|
|
|
|
``"pvforecast.planes"``.
|
|
|
|
|
|
|
|
|
|
max_items_from:
|
|
|
|
|
*``"items"`` only.* Dotted config-field path whose integer
|
|
|
|
|
value caps the number of rendered sub-cards (e.g.
|
|
|
|
|
``"pvforecast.max_planes"``). When ``None`` the length of
|
|
|
|
|
the actual list is used instead.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
form: UiFormType = "text"
|
|
|
|
|
|
|
|
|
|
# select / select_list / map
|
|
|
|
|
options: list[str] = field(default_factory=list)
|
|
|
|
|
options_from: Optional[str] = None
|
2026-08-23 02:26:16 +02:00
|
|
|
options_source: Optional[str] = None
|
2026-08-07 13:13:17 +02:00
|
|
|
param_from: Optional[str] = None
|
|
|
|
|
append_none: bool = False
|
|
|
|
|
|
|
|
|
|
# time_windows
|
|
|
|
|
value_description: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
# items
|
|
|
|
|
item_model: Optional[Any] = None
|
|
|
|
|
item_path: Optional[str] = None
|
|
|
|
|
max_items_from: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 02:26:16 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lazy Registry
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
LazyOptionsSource = Callable[[], list[str]]
|
|
|
|
|
|
|
|
|
|
LAZY_OPTIONS_SOURCES: dict[str, LazyOptionsSource] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_lazy_options_source(key: str, source: LazyOptionsSource) -> None:
|
|
|
|
|
"""Register a lazy options callable that provides the options list.
|
|
|
|
|
|
|
|
|
|
The callable shall provide the full candidate list for a "select_lazy" hint's
|
|
|
|
|
options_source.
|
|
|
|
|
|
|
|
|
|
The callable should be cheap to call repeatedly (e.g. wrapped in
|
|
|
|
|
caching) since it is invoked once per keystroke-driven search request —
|
|
|
|
|
filtering happens in this process, not inside the callable itself.
|
|
|
|
|
"""
|
|
|
|
|
LAZY_OPTIONS_SOURCES[key] = source
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 13:13:17 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Registry
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
UI_HINTS: dict[str, UiHint] = {
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-23 02:26:16 +02:00
|
|
|
# Adapter
|
2026-08-07 13:13:17 +02:00
|
|
|
# ------------------------------------------------------------------
|
2026-08-23 02:26:16 +02:00
|
|
|
"adapter.provider": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.providers",
|
|
|
|
|
),
|
2026-08-07 13:13:17 +02:00
|
|
|
"adapter.homeassistant.config_entity_ids": UiHint(
|
|
|
|
|
form="map",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.load_emr_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.grid_export_emr_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.grid_import_emr_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.pv_production_emr_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.device_measurement_entity_ids": UiHint(
|
|
|
|
|
form="map",
|
|
|
|
|
param_from="devices.measurement_keys",
|
|
|
|
|
options_from="adapter.homeassistant.homeassistant_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.device_instruction_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.eos_device_instruction_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
"adapter.homeassistant.solution_entity_ids": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="adapter.homeassistant.eos_solution_entity_ids",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-23 02:26:16 +02:00
|
|
|
# Database
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"database.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="database.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-07 13:13:17 +02:00
|
|
|
# Devices
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"devices.batteries": UiHint(
|
|
|
|
|
form="items",
|
|
|
|
|
item_path="devices.batteries",
|
|
|
|
|
),
|
|
|
|
|
"devices.electric_vehicles": UiHint(
|
|
|
|
|
form="items",
|
|
|
|
|
item_path="devices.electric_vehicles",
|
|
|
|
|
),
|
|
|
|
|
"devices.home_appliances": UiHint(
|
|
|
|
|
form="items",
|
|
|
|
|
item_path="devices.home_appliances",
|
|
|
|
|
),
|
|
|
|
|
# Sub-field hint for the time_windows field inside each appliance entry
|
|
|
|
|
"devices.home_appliances.cycle_time_windows.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="cycle index (0-based)",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-23 02:26:16 +02:00
|
|
|
# Electricity fee
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"elecfee.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="elecfee.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
|
|
|
|
"elecfee.elecfeefixed.consumption_amt_kwh.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="consumption_amt_kwh [Amt/kWh]",
|
|
|
|
|
),
|
|
|
|
|
"elecfee.elecfeefixed.consumption_percent_amt.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="consumption_percent_amt [%]",
|
|
|
|
|
),
|
|
|
|
|
"elecfee.elecfeefixed.feedin_amt_kwh.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="feedin_amt_kwh [Amt/kWh]",
|
|
|
|
|
),
|
|
|
|
|
"elecfee.elecfeefixed.feedin_percent_amt.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="feedin_percent_amt [%]",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Electricity price
|
2026-08-07 13:13:17 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"elecprice.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="elecprice.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
2026-08-23 02:26:16 +02:00
|
|
|
"elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows": UiHint(
|
2026-08-07 13:13:17 +02:00
|
|
|
form="time_windows",
|
2026-08-23 02:26:16 +02:00
|
|
|
value_description="elecprice_marketprice_amt_kwh [Amt/kWh]",
|
2026-08-07 13:13:17 +02:00
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# EMS
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"ems.mode": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="ems.modes",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-23 02:26:16 +02:00
|
|
|
# Feed-in Tariff
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"feedintariff.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="feedintariff.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
|
|
|
|
"feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows": UiHint(
|
|
|
|
|
form="time_windows",
|
|
|
|
|
value_description="feed_in_tariff_amt_kwh [Amt/kWh]",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-08-07 13:13:17 +02:00
|
|
|
# Load
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"load.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="load.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Optimization
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"optimization.algorithm": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="optimization.algorithms",
|
|
|
|
|
),
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# PV forecast — planes
|
|
|
|
|
# item_model is populated lazily by _ensure_item_models() below.
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"pvforecast.provider": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options_from="pvforecast.providers",
|
|
|
|
|
append_none=True,
|
|
|
|
|
),
|
|
|
|
|
"pvforecast.planes": UiHint(
|
|
|
|
|
form="items",
|
|
|
|
|
item_path="pvforecast.planes",
|
|
|
|
|
max_items_from="pvforecast.max_planes",
|
|
|
|
|
),
|
|
|
|
|
# Per-plane sub-fields; resolved by hint_for_indexed_field()
|
|
|
|
|
"pvforecast.planes.pvtechchoice": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options=["crystSi", "CIS", "CdTe", "Unknown"],
|
|
|
|
|
),
|
|
|
|
|
"pvforecast.planes.mountingplace": UiHint(
|
|
|
|
|
form="select",
|
|
|
|
|
options=["free", "building"],
|
|
|
|
|
),
|
2026-08-23 02:26:16 +02:00
|
|
|
"pvforecast.planes.inverter_model": UiHint(
|
|
|
|
|
form="select_lazy",
|
|
|
|
|
options_source="pvlib.inverters",
|
|
|
|
|
),
|
|
|
|
|
"pvforecast.planes.module_model": UiHint(
|
|
|
|
|
form="select_lazy",
|
|
|
|
|
options_source="pvlib.modules",
|
|
|
|
|
),
|
2026-08-07 13:13:17 +02:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Weather
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
"weather.providers": UiHint(
|
|
|
|
|
form="select_list",
|
|
|
|
|
options_from="weather.providers",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lazy item_model resolution (avoids circular imports at module load time)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_item_models_resolved = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_item_models() -> None:
|
|
|
|
|
"""Populate ``item_model`` on any ``"items"`` hints that need it.
|
|
|
|
|
|
|
|
|
|
Domain model imports are deferred to this function so that importing
|
|
|
|
|
``uihints`` early in the boot sequence does not trigger circular imports.
|
|
|
|
|
"""
|
|
|
|
|
if UI_HINTS["pvforecast.planes"].item_model is None:
|
|
|
|
|
from akkudoktoreos.prediction.pvforecast import ( # noqa: PLC0415
|
|
|
|
|
PVForecastPlaneSetting,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting
|
|
|
|
|
|
|
|
|
|
if UI_HINTS["devices.batteries"].item_model is None:
|
|
|
|
|
from akkudoktoreos.devices.devices import (
|
|
|
|
|
BatteriesCommonSettings,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings
|
|
|
|
|
|
|
|
|
|
if UI_HINTS["devices.electric_vehicles"].item_model is None:
|
|
|
|
|
from akkudoktoreos.devices.devices import (
|
|
|
|
|
BatteriesCommonSettings,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings
|
|
|
|
|
|
|
|
|
|
if UI_HINTS["devices.home_appliances"].item_model is None:
|
|
|
|
|
from akkudoktoreos.devices.devices import (
|
|
|
|
|
HomeApplianceCommonSettings,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
UI_HINTS["devices.home_appliances"].item_model = HomeApplianceCommonSettings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_item_model(hint: UiHint) -> Optional[Any]:
|
|
|
|
|
"""Return the ``item_model`` for an ``"items"`` hint, resolving lazily.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
hint: A ``UiHint`` with ``form == "items"``.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The model class or instance, or ``None`` if unset.
|
|
|
|
|
"""
|
|
|
|
|
global _item_models_resolved
|
|
|
|
|
if not _item_models_resolved:
|
|
|
|
|
_ensure_item_models()
|
|
|
|
|
_item_models_resolved = True
|
|
|
|
|
return hint.item_model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Resolver
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_form_factory(
|
|
|
|
|
hint: UiHint,
|
|
|
|
|
config_details: dict[str, dict],
|
|
|
|
|
) -> Optional[Callable]:
|
|
|
|
|
"""Materialise a ``UiHint`` into a concrete ``update_form_factory`` callable.
|
|
|
|
|
|
|
|
|
|
For ``"items"`` hints this returns ``None`` — the caller must dispatch
|
|
|
|
|
to ``ConfigItemsCard`` separately after checking ``hint.form == "items"``.
|
|
|
|
|
For ``"text"`` this returns ``None`` — the caller uses the default
|
|
|
|
|
plain-text input. All other form types return a callable.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
hint:
|
|
|
|
|
The ``UiHint`` to materialise.
|
|
|
|
|
config_details:
|
|
|
|
|
The fully-resolved config detail dict from
|
|
|
|
|
``create_config_details()``. Used to look up runtime option
|
|
|
|
|
lists via ``options_from`` / ``param_from``.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
A ``(config_name: str, value: str) -> Grid`` factory, or ``None``.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _load_list(key: str) -> list[str]:
|
|
|
|
|
try:
|
|
|
|
|
result = json.loads(config_details[key]["value"])
|
|
|
|
|
return result if isinstance(result, list) else []
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
if hint.form in ("text", "items", "map_items"):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if hint.form == "select":
|
|
|
|
|
options: list[str] = []
|
|
|
|
|
if hint.options_from:
|
|
|
|
|
options = _load_list(hint.options_from)
|
|
|
|
|
if not options:
|
|
|
|
|
options = list(hint.options)
|
|
|
|
|
if hint.append_none and "None" not in options:
|
|
|
|
|
options.append("None")
|
|
|
|
|
return make_config_update_value_form(options)
|
|
|
|
|
|
|
|
|
|
if hint.form == "select_list":
|
|
|
|
|
options = []
|
|
|
|
|
if hint.options_from:
|
|
|
|
|
options = _load_list(hint.options_from)
|
|
|
|
|
if not options:
|
|
|
|
|
options = list(hint.options)
|
|
|
|
|
return make_config_update_list_form(options)
|
|
|
|
|
|
2026-08-23 02:26:16 +02:00
|
|
|
if hint.form == "select_lazy":
|
|
|
|
|
return make_config_update_lazy_select_form(hint.options_source or "")
|
|
|
|
|
|
2026-08-07 13:13:17 +02:00
|
|
|
if hint.form == "map":
|
|
|
|
|
available_values: Optional[list[str]] = None
|
|
|
|
|
available_keys: Optional[list[str]] = None
|
|
|
|
|
if hint.options_from:
|
|
|
|
|
available_values = _load_list(hint.options_from) or None
|
|
|
|
|
if hint.param_from:
|
|
|
|
|
available_keys = _load_list(hint.param_from) or None
|
|
|
|
|
return make_config_update_map_form(available_keys, available_values)
|
|
|
|
|
|
|
|
|
|
if hint.form == "time_windows":
|
|
|
|
|
return make_config_update_time_windows_windows_form(
|
|
|
|
|
value_description=hint.value_description,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return None # unreachable for valid UiFormType values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Suffix-based lookup for indexed sub-model fields
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hint_for_indexed_field(field_name: str, list_path: str) -> Optional[UiHint]:
|
|
|
|
|
"""Return the UiHint for a sub-field inside an 'items' or 'map_items' list.
|
|
|
|
|
|
|
|
|
|
Strips the index segment (numeric for lists, any string for maps) from a
|
|
|
|
|
dotted field name and looks up the canonical hint key.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
field_name:
|
|
|
|
|
Full dotted config name including the index, e.g.
|
|
|
|
|
``"pvforecast.planes.2.mountingplace"`` or
|
|
|
|
|
``"devices.home_appliances.dishwasher1.time_windows"``.
|
|
|
|
|
list_path:
|
|
|
|
|
The ``item_path`` from the parent ``UiHint``, e.g.
|
|
|
|
|
``"pvforecast.planes"`` or ``"devices.home_appliances"``.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The matching ``UiHint``, or ``None`` if none is registered.
|
|
|
|
|
"""
|
|
|
|
|
prefix = list_path + "."
|
|
|
|
|
if not field_name.startswith(prefix):
|
|
|
|
|
return None
|
|
|
|
|
remainder = field_name[len(prefix) :] # e.g. "2.mountingplace" or "dishwasher1.time_windows"
|
|
|
|
|
parts = remainder.split(".", 1)
|
|
|
|
|
if len(parts) < 2:
|
|
|
|
|
return None
|
|
|
|
|
# Accept both numeric (list) and string (map) index segments
|
|
|
|
|
canonical = list_path + "." + parts[1]
|
|
|
|
|
return UI_HINTS.get(canonical)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hint_for_plane_field(field_name: str) -> Optional[UiHint]:
|
|
|
|
|
"""Back-compat wrapper — prefer ``hint_for_indexed_field`` directly."""
|
|
|
|
|
return hint_for_indexed_field(field_name, "pvforecast.planes")
|