Squash-merge PR #251: fix appliance course labels and AC setup timeouts
- Add confirmed Samsung Table_02 washer course mappings (69-79, 88) - Add confirmed dishwasher course mappings (82, 8a, a7, a8, 8c, 88) - Localize new washer/dishwasher course labels in en, cs, nl - Decode device-provided personal washer course names from TLV payloads - Show unrecognized washer enum bytes as 'Unknown (0xNN)' - Normalize select current-state and options through one display path - Bound first-setup subdevice enumeration with a shared time budget Co-authored-by: galaxysj <224385302+galaxysj@users.noreply.github.com>
This commit is contained in:
1 parent
55765fdf9a
commit
00db7890f5
19 files changed
+771
-49
No files matched your search
@@ -202,6 +202,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_POST_TIMEOUT_S: float = 8.0
|
||||
_POLL_TIMEOUT_S: float = 35.0
|
||||
|
||||
# First-discovery subdevice enumeration is part of config-entry setup, so
|
||||
# it must have a finite wall-clock cost. A UUID-prefixed AC whose
|
||||
# /<uuid>/device/0 Collection is absent falls back to individual property
|
||||
# probes; some firmware silently drops unknown prefixed paths instead of
|
||||
# returning 4.04, making the old 10s-per-href scan take several minutes.
|
||||
# Keep enough time for a real blockwise Collection response, then use
|
||||
# short timeouts for the small Property resources, all under one budget.
|
||||
_SUBDEVICE_ENUMERATION_BUDGET_S: float = 15.0
|
||||
_SUBDEVICE_COLLECTION_TIMEOUT_S: float = 10.0
|
||||
_SUBDEVICE_PROPERTY_TIMEOUT_S: float = 1.0
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Per-device logger so every log line (including the base
|
||||
# coordinator's and ObserveManager's) identifies which device it's
|
||||
@@ -646,6 +657,45 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Discovery (runs once on first successful poll)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _subdevice_probe_priority(
|
||||
self, resources: dict[str, dict],
|
||||
) -> tuple[str, ...]:
|
||||
"""Return live primary-entity hrefs in hot/warm-first order.
|
||||
|
||||
A prefixed subdevice without a Collection endpoint has to be probed
|
||||
one Property href at a time. Resolve the master through the same
|
||||
registry used by discovery and put resources that produce primary
|
||||
entities first. This is metadata-driven rather than an AC-specific
|
||||
list: a future composite appliance gets the priority its own registry
|
||||
declares, while unknown devices simply retain the normal href order.
|
||||
"""
|
||||
registry = resolve_registry(
|
||||
resources,
|
||||
device_types=self._identity.device_types if self._identity else (),
|
||||
)
|
||||
if registry is None:
|
||||
return ()
|
||||
|
||||
tier_rank = {'hot': 0, 'warm': 1, 'cold': 2}
|
||||
ranked = []
|
||||
for order, href in enumerate(resources):
|
||||
primary_caps = [
|
||||
capability
|
||||
for capability in registry.capabilities.get(href, ())
|
||||
if any(
|
||||
desc.entity_category is None
|
||||
for desc in capability.entities
|
||||
)
|
||||
]
|
||||
if not primary_caps:
|
||||
continue
|
||||
rank = min(
|
||||
tier_rank.get(capability.poll_tier, 2)
|
||||
for capability in primary_caps
|
||||
)
|
||||
ranked.append((rank, order, href))
|
||||
return tuple(href for _, _, href in sorted(ranked))
|
||||
|
||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""One-time (first discovery only) probe for sibling indoor
|
||||
subdevices on this connection (issue #177) -- see
|
||||
@@ -671,6 +721,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
resources,
|
||||
oic_res,
|
||||
probe_log=lambda href, found: probes.__setitem__(href, found),
|
||||
preferred_hrefs=self._subdevice_probe_priority(resources),
|
||||
time_budget=self._SUBDEVICE_ENUMERATION_BUDGET_S,
|
||||
collection_timeout=self._SUBDEVICE_COLLECTION_TIMEOUT_S,
|
||||
property_timeout=self._SUBDEVICE_PROPERTY_TIMEOUT_S,
|
||||
)
|
||||
self.subdevices = subdevices
|
||||
self._subdevice_probes = probes
|
||||
|
||||
@@ -22,6 +22,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import time as dt_time
|
||||
import string
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
@@ -323,12 +324,56 @@ def cycle_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
def personal_course_labels(resources, href='/wm/personalcourse/vs/0'):
|
||||
"""Return device-provided personal course names keyed by course code.
|
||||
|
||||
Populated entries use a small TLV payload. The leading field is
|
||||
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
|
||||
settings and are intentionally left uninterpreted. Empty slots are
|
||||
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
|
||||
opaque device data can never become a misleading label.
|
||||
"""
|
||||
rep = resources.get(href) or {}
|
||||
labels = {}
|
||||
for entry in rep.get('x.com.samsung.da.courses') or []:
|
||||
if not isinstance(entry, str) or '_' not in entry:
|
||||
continue
|
||||
code, encoded = entry.split('_', 1)
|
||||
try:
|
||||
payload = bytes.fromhex(encoded)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(payload) < 3 or payload[0] != 0x01:
|
||||
continue
|
||||
name_length = payload[1]
|
||||
if name_length == 0 or len(payload) < 2 + name_length:
|
||||
continue
|
||||
try:
|
||||
name = payload[2:2 + name_length].decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if name.strip() and name.isprintable():
|
||||
labels[code.upper()] = name
|
||||
return labels
|
||||
|
||||
|
||||
def washer_cycle_fallback(value, resources):
|
||||
"""Label an untranslated washer course without guessing its meaning."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if label := personal_course_labels(resources).get(value.upper()):
|
||||
return label
|
||||
if len(value) == 2 and all(char in string.hexdigits for char in value):
|
||||
return f'Unknown (0x{value.upper()})'
|
||||
return None
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get("x.com.samsung.da.st.courseTable")
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are shared across
|
||||
@@ -346,6 +391,11 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
instead of borrowing a label from another board generation --
|
||||
translating a new table is a translations-only change.
|
||||
|
||||
The raw course code remains writable regardless; its display uses
|
||||
display_fn when supplied, otherwise it remains raw. display_fn is an
|
||||
optional family-specific fallback for untranslated raw values --
|
||||
select.py applies it after catalog lookup to both state and options.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource and no evidence its codes vary by table the way washer/
|
||||
dryer's do.
|
||||
@@ -367,6 +417,7 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
|
||||
display_fn=display_fn,
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from .laundry import (
|
||||
hex_pairs,
|
||||
option_value,
|
||||
option_write,
|
||||
washer_cycle_fallback,
|
||||
)
|
||||
|
||||
# Course_XX hex code labels (translations/en.json,
|
||||
@@ -50,6 +51,13 @@ from .laundry import (
|
||||
# options' MostUsed_* entry was considered as a fallback source (its first
|
||||
# byte matches the selected Course_XX on both dumps), but the remaining
|
||||
# bytes don't decode to any confirmed course code, so it isn't used.
|
||||
#
|
||||
# The owner of a Korean Table_02 washer confirmed the names for its newer
|
||||
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
|
||||
# live only in the table-scoped translation catalog; codes not confirmed by
|
||||
# the owner or device metadata continue to use the safe unknown-code fallback,
|
||||
# via washer_cycle_fallback (personal-course names, then 'Unknown (0xNN)').
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
|
||||
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
||||
@@ -250,6 +258,7 @@ WASHER_COURSE = Capability(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
display_fn=washer_cycle_fallback,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
|
||||
@@ -19,6 +19,7 @@ WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||
DisplayFn = Callable[[Any, dict], Any] | None
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -77,6 +78,10 @@ class SelectDesc(SamsungEntityDescription):
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: str | None = None # resource field that contains the live options list
|
||||
# Optional device-specific fallback for values absent from the translation
|
||||
# catalog. Receives (raw_value, canonical_resources); select.py applies it
|
||||
# identically to the current state and every option.
|
||||
display_fn: DisplayFn = None
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ appliance's own bookkeeping, not evidence of a second indoor unit -- see
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -221,12 +222,12 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
return "/" + "/".join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
|
||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||
shared tolerated-absence posture for both callers below."""
|
||||
try:
|
||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||
code, pl = sess.get(list(path_segs), timeout=timeout)
|
||||
if code == 0x45 and pl:
|
||||
return cbor2.loads(pl)
|
||||
except Exception:
|
||||
@@ -234,19 +235,25 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
return None
|
||||
|
||||
|
||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
def _get_batch(
|
||||
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
|
||||
) -> dict[str, dict]:
|
||||
"""GET a Samsung Collection resource and parse it the same way
|
||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
def _get_property(
|
||||
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
|
||||
) -> dict:
|
||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||
batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
|
||||
from `/device/0`'s batch, so it needs its own RETRIEVE."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||
`/oic/res` on the Pattern A reporter's board but absent from
|
||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||
single Property map, not a [devcol-rep, ...] list."""
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@@ -255,6 +262,11 @@ def enumerate_subdevices(
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Callable[[str, bool], None] | None = None,
|
||||
*,
|
||||
preferred_hrefs: Sequence[str] = (),
|
||||
time_budget: float | None = None,
|
||||
collection_timeout: float = 10.0,
|
||||
property_timeout: float = 10.0,
|
||||
) -> tuple[list[Subdevice], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||
connection.
|
||||
@@ -269,6 +281,13 @@ def enumerate_subdevices(
|
||||
or not it answered, so diagnostics can tell "checked, nothing there"
|
||||
apart from "never checked".
|
||||
|
||||
`preferred_hrefs` only changes the order of the flat Property fallback;
|
||||
it never filters the device's resource surface. When `time_budget` is
|
||||
supplied, probes are bounded by one shared monotonic deadline and this
|
||||
returns every candidate/resource confirmed before it. This makes first
|
||||
setup finite even when firmware silently drops unknown paths instead of
|
||||
returning 4.04.
|
||||
|
||||
Every candidate whose seed answers with a non-empty batch is returned
|
||||
here -- this function can't tell a real sibling from an unused
|
||||
SmartThings slot that answers the same shape; that requires
|
||||
@@ -282,6 +301,33 @@ def enumerate_subdevices(
|
||||
# casing, and probing it twice would materialize the same physical
|
||||
# subdevice as two Subdevice candidates.
|
||||
probed_ids: set[str] = set()
|
||||
deadline = (
|
||||
time.monotonic() + max(0.0, time_budget)
|
||||
if time_budget is not None else None
|
||||
)
|
||||
budget_exhausted = False
|
||||
|
||||
def _next_timeout(maximum: float) -> float | None:
|
||||
"""Clamp one probe to the remaining enumeration wall-clock budget."""
|
||||
nonlocal budget_exhausted
|
||||
if deadline is None:
|
||||
return maximum
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
budget_exhausted = True
|
||||
return None
|
||||
return min(maximum, remaining)
|
||||
|
||||
def _flat_probe_hrefs():
|
||||
"""Preferred live-state hrefs first, then every remaining master href."""
|
||||
seen = set()
|
||||
for href in preferred_hrefs:
|
||||
if href in resources and href not in seen:
|
||||
seen.add(href)
|
||||
yield href
|
||||
for href in sorted(resources):
|
||||
if href not in seen:
|
||||
yield href
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
@@ -296,7 +342,10 @@ def enumerate_subdevices(
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, "device", "0")
|
||||
batch = _get_batch(sess, seed)
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
return
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||
@@ -307,10 +356,12 @@ def enumerate_subdevices(
|
||||
# doesn't always expose its own `/<uuid>/device/0` Collection. With
|
||||
# no Collection to seed from and no per-UUID entry in /oic/res to
|
||||
# enumerate hrefs from, the only signal left is that a composite
|
||||
# device's siblings share the master's own resource surface -- so
|
||||
# probe every href the master answered this cycle, individually,
|
||||
# under this UUID's prefix, and keep whichever answer. Each is a
|
||||
# plain tolerated-404 RETRIEVE.
|
||||
# device's siblings are the same physical board family as the
|
||||
# subdevice this config entry already talks to -- so probe every
|
||||
# href the master itself answered this cycle, individually, under
|
||||
# this UUID's prefix, and keep whichever ones answer before the
|
||||
# optional enumeration deadline. Each is a plain tolerated-404
|
||||
# RETRIEVE, same posture as every other probe in this function.
|
||||
#
|
||||
# Known gap: a firmware that echoes the master's own state back
|
||||
# under an unrecognized prefix, rather than 4.04ing, would pass
|
||||
@@ -321,12 +372,15 @@ def enumerate_subdevices(
|
||||
# reps against the master's own values for the same hrefs.
|
||||
flat_hrefs = []
|
||||
first = True
|
||||
for href in sorted(resources):
|
||||
for href in _flat_probe_hrefs():
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
actual = f"/{sub_id}{href}"
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
|
||||
_probed(actual, rep)
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
@@ -353,6 +407,8 @@ def enumerate_subdevices(
|
||||
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
||||
for sub_id in listed:
|
||||
_probe_prefixed(sub_id)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
|
||||
@@ -374,6 +430,8 @@ def enumerate_subdevices(
|
||||
)
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted(
|
||||
@@ -390,8 +448,11 @@ def enumerate_subdevices(
|
||||
# this replaces from identity.py.
|
||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||
for n in indices:
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
seed = ("device", str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
@@ -399,18 +460,26 @@ def enumerate_subdevices(
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0: listed in /oic/res on some boards but never in
|
||||
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
|
||||
# count (numofsubdevice), confirmed read-only -- captured for
|
||||
# diagnostics only, folded into the merged resources dict like any
|
||||
# other href (see airconditioner._AC_IGNORED). Not a gate:
|
||||
# discover_partitioned's entity-level liveness check decides
|
||||
# materialization without it.
|
||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||
# into the merged resources dict like any other href (see
|
||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
||||
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
|
||||
# liveness check decides materialization correctly without it, and only
|
||||
# this one board family is known to expose it at all. Whether it agrees
|
||||
# with the number of subdevices actually materialized is the
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ("multidevice", "vs", "0")
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is not None:
|
||||
multidevice = _get_property(sess, multidevice_seed, timeout)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: str | 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
|
||||
@@ -70,11 +70,19 @@ def _display(value, translation_key: str | None):
|
||||
if translation_key:
|
||||
known = translated_states("select", translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either untranslated, or its
|
||||
# options deliberately aren't (an unrecognized course table).
|
||||
return value
|
||||
if translated := _translation_state(value, 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). Give an explicit
|
||||
# device-specific fallback the opportunity to make an opaque value
|
||||
# readable; otherwise the raw device value remains the best choice.
|
||||
if fallback_fn is None:
|
||||
return value
|
||||
elif translated := _translation_state(value, known):
|
||||
return translated
|
||||
if fallback_fn is not None:
|
||||
fallback = fallback_fn(value)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
if value.islower():
|
||||
return value.replace("_", " ").title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||
@@ -85,7 +93,16 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc = cast(SelectDesc, bound.desc)
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, self.translation_key) for o in 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 = 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)
|
||||
@@ -106,17 +123,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, self.translation_key) for o in self._raw_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 _display(raw, self.translation_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 _display(o, self.translation_key) == option),
|
||||
(o for o in self._raw_options() if self._display_option(o) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
@@ -297,7 +297,13 @@
|
||||
"07": "Předoplach",
|
||||
"8d": "Hrnce a pánve",
|
||||
"8e": "Plast",
|
||||
"8f": "Dětské potřeby"
|
||||
"8f": "Dětské potřeby",
|
||||
"82": "Automatický",
|
||||
"8a": "Normální",
|
||||
"a7": "Intenzivní",
|
||||
"a8": "Expresní",
|
||||
"8c": "Extra tichý",
|
||||
"88": "Samočištění"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -621,12 +627,30 @@
|
||||
"60": "Samočištění+",
|
||||
"65": "Barevné prádlo",
|
||||
"66": "Džíny",
|
||||
"69": "AI praní",
|
||||
"6a": "Vlna",
|
||||
"6b": "Džíny",
|
||||
"6c": "Halenky",
|
||||
"6d": "Jemné prádlo",
|
||||
"6e": "Sportovní oblečení",
|
||||
"6f": "Ložní prádlo",
|
||||
"70": "Ručníky",
|
||||
"71": "Rychlé praní",
|
||||
"72": "Košile",
|
||||
"73": "Dezinfekce",
|
||||
"74": "Čištění bubnu",
|
||||
"75": "Outdoor",
|
||||
"76": "Dětské potřeby",
|
||||
"77": "Bavlna",
|
||||
"78": "Máchání + odstřeďování",
|
||||
"79": "Pouze odstřeďování",
|
||||
"7c": "Bílé prádlo",
|
||||
"7d": "Ložní prádlo/nepromokavé",
|
||||
"7e": "Samočištění",
|
||||
"7f": "Vlna/jemné",
|
||||
"86": "Hloubkové praní",
|
||||
"87": "Stažený program",
|
||||
"88": "Péče o domácí mazlíčky",
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken"
|
||||
}
|
||||
|
||||
@@ -297,7 +297,13 @@
|
||||
"07": "Pre blast",
|
||||
"8d": "Pots and pans",
|
||||
"8e": "Plastic",
|
||||
"8f": "Baby Care"
|
||||
"8f": "Baby Care",
|
||||
"82": "Auto",
|
||||
"8a": "Normal",
|
||||
"a7": "Heavy",
|
||||
"a8": "Express",
|
||||
"8c": "Extra Silence",
|
||||
"88": "Self Clean"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -621,12 +627,30 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colors",
|
||||
"66": "Denim",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Whites",
|
||||
"7d": "Bedding/Waterproof",
|
||||
"7e": "Self-Clean",
|
||||
"7f": "Wool/Delicate",
|
||||
"86": "Deep Wash",
|
||||
"87": "Download",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
|
||||
@@ -446,7 +446,16 @@
|
||||
"07": "Prelavado intenso",
|
||||
"8d": "Ollas y sartenes",
|
||||
"8e": "Plástico",
|
||||
"8f": "Cuidado del bebé"
|
||||
"8f": "Cuidado del bebé",
|
||||
"82": "Automático",
|
||||
"8a": "Normal",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silencioso",
|
||||
"88": "Autolimpieza",
|
||||
"85": "Delicado",
|
||||
"0c": "Express",
|
||||
"0d": "Autolimpieza"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -488,7 +497,9 @@
|
||||
"4c": "Renovación de aire",
|
||||
"51": "Eco algodón",
|
||||
"53": "Secado IA+",
|
||||
"4e": "Autosecado"
|
||||
"4e": "Autosecado",
|
||||
"26": "Aireación",
|
||||
"2a": "Cuidado higiénico+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -777,7 +788,26 @@
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras"
|
||||
"96": "Menos microfibras",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
"6c": "Blusas",
|
||||
"6d": "Delicados",
|
||||
"6e": "Ropa deportiva",
|
||||
"6f": "Ropa de cama",
|
||||
"70": "Toallas",
|
||||
"71": "Lavado rápido",
|
||||
"72": "Camisas",
|
||||
"73": "Desinfección",
|
||||
"74": "Limpieza de Tambor",
|
||||
"75": "Exterior",
|
||||
"76": "Cuidado del bebé",
|
||||
"77": "Algodón",
|
||||
"78": "Aclarar + Centrifugar",
|
||||
"79": "Solo centrifugar",
|
||||
"88": "Cuidado de mascotas",
|
||||
"35": "Algodón E"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -297,7 +297,16 @@
|
||||
"07": "Prelavaggio intenso",
|
||||
"8d": "Pentole e padelle",
|
||||
"8e": "Plastica",
|
||||
"8f": "Bambini"
|
||||
"8f": "Bambini",
|
||||
"82": "Automatico",
|
||||
"8a": "Normale",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silenzioso",
|
||||
"88": "Autopulizia",
|
||||
"85": "Delicato",
|
||||
"0c": "Express",
|
||||
"0d": "Autopulizia"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -339,7 +348,9 @@
|
||||
"4c": "Rinfresca",
|
||||
"51": "Eco cotone",
|
||||
"53": "Asciugatura AI+",
|
||||
"4e": "Autoasciugatura"
|
||||
"4e": "Autoasciugatura",
|
||||
"26": "Arieggiatura",
|
||||
"2a": "Cura igienica+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -628,7 +639,26 @@
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre"
|
||||
"96": "Riduci microfibre",
|
||||
"69": "Lavaggio AI",
|
||||
"6a": "Lana",
|
||||
"6b": "Jeans",
|
||||
"6c": "Camicette",
|
||||
"6d": "Delicati",
|
||||
"6e": "Abbigliamento sportivo",
|
||||
"6f": "Biancheria da letto",
|
||||
"70": "Asciugamani",
|
||||
"71": "Lavaggio rapido",
|
||||
"72": "Camicie",
|
||||
"73": "Igienizzante",
|
||||
"74": "Pulizia cestello",
|
||||
"75": "Capi outdoor",
|
||||
"76": "Bambini",
|
||||
"77": "Cotone",
|
||||
"78": "Risciacquo+Centrifuga",
|
||||
"79": "Solo centrifuga",
|
||||
"88": "Cura animali",
|
||||
"35": "Cotone E"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -297,7 +297,16 @@
|
||||
"07": "애벌 세척",
|
||||
"8d": "냄비 및 팬",
|
||||
"8e": "플라스틱",
|
||||
"8f": "젖병 살균"
|
||||
"8f": "젖병 살균",
|
||||
"82": "자동",
|
||||
"8a": "표준",
|
||||
"a7": "강력",
|
||||
"a8": "급속",
|
||||
"8c": "저소음",
|
||||
"88": "내부 세척",
|
||||
"85": "섬세",
|
||||
"0c": "급속",
|
||||
"0d": "내부 세척"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -339,7 +348,9 @@
|
||||
"4c": "에어 리프레시",
|
||||
"51": "에코 면",
|
||||
"53": "AI 맞춤건조+",
|
||||
"4e": "자체 건조"
|
||||
"4e": "자체 건조",
|
||||
"26": "송풍",
|
||||
"2a": "살균건조+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -628,7 +639,26 @@
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감"
|
||||
"96": "미세플라스틱저감",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
"6c": "블라우스",
|
||||
"6d": "섬세의류",
|
||||
"6e": "운동복",
|
||||
"6f": "이불",
|
||||
"70": "타월",
|
||||
"71": "쾌속세탁",
|
||||
"72": "셔츠",
|
||||
"73": "살균",
|
||||
"74": "무세제통세척",
|
||||
"75": "아웃도어",
|
||||
"76": "아기옷",
|
||||
"77": "면",
|
||||
"78": "헹굼+탈수",
|
||||
"79": "탈수만",
|
||||
"88": "펫케어",
|
||||
"35": "에코 면"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -297,7 +297,13 @@
|
||||
"07": "Voorspoelen",
|
||||
"8d": "Potten en pannen",
|
||||
"8e": "Kunststof",
|
||||
"8f": "Babyverzorging"
|
||||
"8f": "Babyverzorging",
|
||||
"82": "Auto",
|
||||
"8a": "Normaal",
|
||||
"a7": "Intensief",
|
||||
"a8": "Express",
|
||||
"8c": "Extra stil",
|
||||
"88": "Zelfreiniging"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -621,12 +627,30 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Bonte was",
|
||||
"66": "Spijkergoed",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wol",
|
||||
"6b": "Spijkergoed",
|
||||
"6c": "Blouses",
|
||||
"6d": "Fijne was",
|
||||
"6e": "Sportkleding",
|
||||
"6f": "Beddengoed",
|
||||
"70": "Handdoeken",
|
||||
"71": "Snelle was",
|
||||
"72": "Overhemden",
|
||||
"73": "Hygiëne",
|
||||
"74": "Trommel reinigen",
|
||||
"75": "Outdoor",
|
||||
"76": "Babyverzorging",
|
||||
"77": "Katoen",
|
||||
"78": "Spoelen + centrifugeren",
|
||||
"79": "Alleen centrifugeren",
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/waterdicht",
|
||||
"7e": "Self Clean",
|
||||
"7f": "Wol/fijne was",
|
||||
"86": "Diep wassen",
|
||||
"87": "Gedownload",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user