The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
88 lines
3.6 KiB
Python
88 lines
3.6 KiB
Python
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
|
|
|
|
A read of `GET /api/states` through the add-on's own session (hass.states()),
|
|
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
|
|
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
|
|
served with its age, or an empty one that says why: the route never fails.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
CACHE_S = 30.0
|
|
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
|
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
|
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
|
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
|
|
|
|
|
def row(state: dict) -> dict | None:
|
|
"""One state object -> {entity_id, name, state, unit, device_class}."""
|
|
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
|
|
return None
|
|
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
|
|
return {"entity_id": state["entity_id"],
|
|
"name": str(attrs.get("friendly_name") or state["entity_id"]),
|
|
"state": state.get("state"),
|
|
"unit": attrs.get("unit_of_measurement"),
|
|
"device_class": attrs.get("device_class")}
|
|
|
|
|
|
def matches(r: dict, kind: str) -> bool:
|
|
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
|
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
|
unit, cls = r.get("unit"), r.get("device_class")
|
|
if kind in ("power", "power_w"):
|
|
return cls == "power" or unit in _POWER_UNITS
|
|
if kind == "energy":
|
|
return cls == "energy" or unit in _ENERGY_UNITS
|
|
if kind == "pct":
|
|
return unit == "%"
|
|
if kind == "number":
|
|
return r["entity_id"].startswith(("number.", "input_number."))
|
|
return True
|
|
|
|
|
|
class EntityCache:
|
|
"""The last entity list read from Home Assistant, at most CACHE_S old."""
|
|
|
|
def __init__(self, hass, clock=time.monotonic):
|
|
self.hass = hass
|
|
self.clock = clock
|
|
self.rows: dict | None = None
|
|
self.read_at: float | None = None
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def get(self) -> tuple[dict | None, float | None]:
|
|
"""(rows by entity id, age in s), or (None, None) if never read."""
|
|
async with self._lock: # one HA read however many pickers ask
|
|
now = self.clock()
|
|
if self.read_at is None or now - self.read_at >= CACHE_S:
|
|
states = await self.hass.states()
|
|
if states is not None:
|
|
rows = (row(s) for s in states)
|
|
self.rows = {r["entity_id"]: r for r in rows if r}
|
|
self.read_at = now
|
|
if self.read_at is None:
|
|
return None, None
|
|
return self.rows, now - self.read_at
|
|
|
|
def peek(self) -> dict | None:
|
|
"""The cached rows if they are fresh, without asking Home Assistant."""
|
|
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
|
return None
|
|
return self.rows
|
|
|
|
async def payload(self, kind: str = "") -> dict:
|
|
"""The route's JSON: matching entities first, then the rest."""
|
|
rows, age = await self.get()
|
|
if rows is None:
|
|
return {"available": False, "text": DOWN_TEXT, "entities": []}
|
|
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
|
|
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
|
|
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
|
|
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
|
|
if age >= CACHE_S:
|
|
out["text"] = STALE_TEXT
|
|
return out
|