Four parallel reviews (reuse, simplification, efficiency, altitude). The two that change behavior: - observe() could report "changed" on every poll forever, rewriting the config entry each time. If both tokens name the same slot with different payloads -- a downloaded program with its settings tweaked for one run is exactly that shape -- each pass wrote the default's blob then the one-shot's over it, so neither was ever already stored. On the SD-card installs this integration runs on, sustained entry rewrites are the one cost here that bites. The end state is stable, so "changed" is now start-vs-end, not per-assignment. - The write path copied every tracked href to read one rep, walking past the accessor added to avoid exactly that. New entity_rep() does the merge for a single href; cycle_write drops the resources parameter it never used. Structure: - device_resources() is a second accessor giving the pure device view, used by diagnostics and the debug read service. That deletes strip_synthetic, the _SYNTHETIC_KEY_PREFIX convention and the redact filter added last commit: "a dump is what the device said" is now which method you call rather than something every future exporter has to remember. - apply_cloud_courses() is the single mutation path. The flow was reaching past the coordinator into the store and relying on a later call to persist and invalidate for it; nine names are also now one entry write, not nine. - option_value/hex_pairs move to capabilities/common.py. The duplicate's stated reason -- that the coordinator shouldn't import from registry.capabilities -- was simply false; it already does, and so does learned.py. The real constraint is narrower: laundry.py imports cloudcourse, so the reverse would be a cycle. Dropped rather than kept: - The cloud-vs-translated-local-course name check, and catalog. translated_state_labels with it. The catalog this process can read is English while the dropdown is localized in the frontend, so it rejected "Cotton" for a German user seeing "Baumwolle" and missed the real collision when they typed "Baumwolle" -- wrong in both directions outside one locale, against an outcome option ordering already makes deterministic. The checks that survive compare strings that are the same in every locale: the user's own names, and the device's personal-course labels. - stored(), clear()/forget_cloud_courses(), blob(), download_course() -- no production callers. stored() was a template artifact whose docstring described a caller that cannot exist here. Diagnostics gains a cloud_courses block, which the store was missing next to learned_modes -- payloads and which slots are named, but not the names themselves, since those are the user's words and dumps get pasted publicly. Kept against one reviewer's advice: option_tokens (two others called generalizing option_write the right direction) and select._display's uncatalogued branch, which names a condition the old fallback-is-None proxy only got right by accident. Deferred: making the store per-subdevice. It is MAIN-only today and no device seen advertises cloud programs elsewhere; the limitation is now documented where it is made.
138 lines
5.8 KiB
Python
138 lines
5.8 KiB
Python
"""Select platform for Local Things."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import cast
|
|
|
|
from homeassistant.components.select import SelectEntity
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .catalog import translated_states
|
|
from .const import DOMAIN
|
|
from .coordinator import LocalThingsCoordinator
|
|
from .entity import LocalThingsEntity, _is_included
|
|
from .registry.entities import SelectDesc
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
|
async_add_entities(
|
|
LocalThingsSelect(coordinator, b)
|
|
for b in coordinator.bound
|
|
if isinstance(b.desc, SelectDesc) and _is_included(b, coordinator)
|
|
)
|
|
|
|
|
|
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
|
|
|
|
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
|
"""Return the catalog state `value` normalizes to, else None.
|
|
|
|
Samsung reports options in whatever casing the resource uses
|
|
('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state
|
|
translations up by a lowercase key. Only values the catalog actually
|
|
knows are normalized -- an unrecognized (or future) vendor value keeps
|
|
its own readable form rather than becoming an untranslatable slug.
|
|
"""
|
|
direct = value.lower().replace(" ", "_")
|
|
if direct in known:
|
|
return direct
|
|
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
|
|
return snake if snake in known else None
|
|
|
|
|
|
def _display(value, translation_key: str | None, fallback_fn=None):
|
|
"""Turn a raw device option/state value into what's shown in the UI.
|
|
|
|
`translation_key` is the entity's already-resolved key (it can itself
|
|
be a callable -- see entities.py -- so callers pass the resolved
|
|
value, not the raw descriptor field).
|
|
|
|
An entity with a translation_key looks its state up in the shipped
|
|
translation catalog, whose state keys are lowercase, and the device
|
|
still expects that same raw casing back on write (mapped back via
|
|
_raw_options()). Everything else has no catalog lookup, so there's no
|
|
reason to destroy the device's own casing: only two cosmetic fixups
|
|
apply, title-casing a fully lowercase token ("voice") and spacing a
|
|
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
|
|
("AI Wash") matches neither and passes through unchanged.
|
|
"""
|
|
if not isinstance(value, str):
|
|
return value
|
|
known = translated_states("select", translation_key) if translation_key else frozenset()
|
|
if translated := _translation_state(value, known):
|
|
return translated
|
|
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
|
|
return fallback
|
|
if translation_key and not known:
|
|
# No state table for this key: either the entity isn't translated at
|
|
# all, or its name is translated but its options deliberately aren't
|
|
# (an unrecognized course table, say). Nothing named this value, so
|
|
# the raw device value is the best choice -- the cosmetic reshaping
|
|
# below would only mangle an opaque code, turning a course '0E' into
|
|
# '0 E'. Reached only when the fallback *declined* the value, not
|
|
# merely when none was supplied: cycle_select always supplies one now
|
|
# (it labels cloud programs) and returns None for everything else.
|
|
return value
|
|
if value.islower():
|
|
return value.replace("_", " ").title()
|
|
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
|
|
|
|
|
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
|
super().__init__(coordinator, bound)
|
|
desc = cast(SelectDesc, bound.desc)
|
|
if not desc.options_field and not callable(desc.options):
|
|
self._attr_options = [self._display_option(o) for o in desc.options]
|
|
|
|
def _display_option(self, value):
|
|
"""Normalize both current state and options through one path."""
|
|
display_fn = cast(SelectDesc, self._bound.desc).display_fn
|
|
fallback_fn = (
|
|
(lambda raw: display_fn(raw, self._resources)) if display_fn is not None else None
|
|
)
|
|
return _display(value, self.translation_key, fallback_fn)
|
|
|
|
def _raw_options(self) -> list[str]:
|
|
desc = cast(SelectDesc, self._bound.desc)
|
|
if callable(desc.options):
|
|
# Per-device option list computed from the full resource
|
|
# snapshot -- e.g. a course list decoded from a sibling
|
|
# resource. No static fallback: when unpopulated, the callable
|
|
# returns [] and exists_fn suppresses the entity entirely. Uses
|
|
# this subdevice's canonical view (issue #177), not the raw
|
|
# snapshot -- see LocalThingsEntity._resources.
|
|
return list(desc.options(self._resources) or [])
|
|
if desc.options_field:
|
|
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
|
return list(rep.get(desc.options_field) or [])
|
|
return list(desc.options)
|
|
|
|
@property
|
|
def options(self) -> list[str]:
|
|
desc = cast(SelectDesc, self._bound.desc)
|
|
if desc.options_field or callable(desc.options):
|
|
return [self._display_option(o) for o in self._raw_options()]
|
|
return self._attr_options
|
|
|
|
@property
|
|
def current_option(self):
|
|
raw = (self.coordinator.data or {}).get(self._state_key)
|
|
return self._display_option(raw)
|
|
|
|
async def async_select_option(self, option: str) -> None:
|
|
raw = next(
|
|
(o for o in self._raw_options() if self._display_option(o) == option),
|
|
option,
|
|
)
|
|
await self.coordinator.async_send_command(self._bound, raw)
|