Files
localthings/custom_components/localthings/select.py
T
Marc Billow 2265c52c77 laundry: apply cleanup review to the cloud-cycle branch
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.
2026-08-10 03:30:23 +00:00

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)