diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 66bc4a4..a2ba287 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 + # //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 diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index a117439..73db441 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -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 ``; later fields contain a description and + settings and are intentionally left uninterpreted. Empty slots are + encoded as ``_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, ) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 68e464d..f948736 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -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", diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 8dd06eb..fdfdb02 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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 diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 9c4ea06..988b202 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -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 `//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/ 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 diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index a58abcb..d7946db 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -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) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 905178d..f5e94de 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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" } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 944c745..604b9f4 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" } diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 02c3080..dceaa23 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -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": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index ca02b64..85d5248 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -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": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index aae4012..3fed8be 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -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": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 64f493f..9a17113 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" } diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 82b359e..2cae297 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -59,6 +59,54 @@ class TestCourseHelpers: assert laundry.option_value(opts, "Course") == "1C" assert laundry.option_value(opts, "Missing") is None + def test_decodes_device_provided_personal_course_names(self): + """Real /wm/personalcourse/vs/0 entries from the reported washer. + + The first TLV field contains a byte-counted UTF-8 name. It is the only + course-name metadata in the diagnostics and is safe to display without + assigning an inferred meaning to any standard course code. + """ + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': [ + 'F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1', + 'F2_00', + 'F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9', + ], + }, + } + first_name = bytes.fromhex('EC868DEC98B7').decode('utf-8') + second_name = bytes.fromhex('EC9A94EAB8B0EBB3B4').decode('utf-8') + assert laundry.personal_course_labels(resources) == { + 'F1': first_name, + 'F3': second_name, + } + + def test_personal_course_names_reject_malformed_payloads(self): + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': [ + 'F1_not-hex', + 'F2_0106AA', + 'F3_020141', + None, + ], + }, + } + assert laundry.personal_course_labels(resources) == {} + + def test_washer_cycle_fallback_uses_labels_then_safe_unknown_text(self): + resources = { + '/wm/personalcourse/vs/0': { + 'x.com.samsung.da.courses': ['F1_0106EC868DEC98B7'], + }, + } + expected = bytes.fromhex('EC868DEC98B7').decode('utf-8') + assert laundry.washer_cycle_fallback('F1', resources) == expected + assert laundry.washer_cycle_fallback('69', resources) == 'Unknown (0x69)' + assert laundry.washer_cycle_fallback('6f', resources) == 'Unknown (0x6F)' + assert laundry.washer_cycle_fallback('Normal', resources) is None + class TestCourseCodesFromSupportedOptions: """cycle_options()'s fallback for boards that populate diff --git a/tests/test_select_display.py b/tests/test_select_display.py index 1294874..fe2e4d6 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -46,3 +46,9 @@ def test_known_camel_case_state_uses_snake_case_translation_key(): def test_display_passes_through_non_string_values(): assert _display(None, _UNTRANSLATED) is None + + +def test_display_uses_fallback_when_translation_has_no_state_table(): + assert _display('69', 'cycle', lambda value: f'Unknown (0x{value})') == ( + 'Unknown (0x69)' + ) diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 954e589..3ced893 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -6,6 +6,10 @@ and callable forms of SelectDesc.options. from typing import ClassVar, cast from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.registry.capabilities.laundry import ( + cycle_select, + washer_cycle_fallback, +) from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity from custom_components.localthings.registry.entities import SelectDesc @@ -113,3 +117,79 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value(): assert entity.options[-1] == "Future Vendor Mode" await entity.async_select_option("Future Vendor Mode") assert coordinator.writes == ["FutureVendorMode"] + + +async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path(): + """Regression for Course_69/EditCourseList_696F... from real hardware.""" + + class _WritableCoordinator(_FakeCoordinator): + data: ClassVar[dict] = {"cycle": "69"} + + def __init__(self, last_resources): + super().__init__(last_resources) + self.writes = [] + + async def async_send_command(self, bound, value): + self.writes.append(value) + + desc = cycle_select( + translation_key="washer_cycle", + icon="mdi:washing-machine", + table_href="/st/washercourse/vs/0", + display_fn=washer_cycle_fallback, + ) + capability = Capability(href="/course/vs/0", entities=(desc,)) + bound = BoundEntity(href="/course/vs/0", capability=capability, desc=desc) + resources = { + "/course/vs/0": {"x.com.samsung.da.options": ["Course_69"]}, + "/st/washercourse/vs/0": { + "x.com.samsung.da.st.courseTable": "Table_02", + }, + "/wm/editcourse/vs/0": { + "x.com.samsung.da.editCourseList": ( + "EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3" + ), + }, + "/wm/personalcourse/vs/0": { + "x.com.samsung.da.courses": [ + "F1_0106EC868DEC98B7", + "F3_0109EC9A94EAB8B0EBB3B4", + ], + }, + } + coordinator = _WritableCoordinator(resources) + entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound) + first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8") + second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8") + + # Known catalog states stay as HA translation keys; the frontend renders + # this confirmed Table_02 mapping as "AI Wash". + assert entity.current_option == "69" + assert entity.options == [ + "69", + "6f", + "75", + "78", + "01", + "71", + "96", + "88", + "70", + "6d", + "6a", + "73", + "76", + "72", + "6c", + "6e", + "6b", + "77", + "74", + "79", + first_name, + second_name, + ] + + await entity.async_select_option("6f") + await entity.async_select_option(first_name) + assert coordinator.writes == ["6F", "F1"] diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py index c839c2c..2d371d8 100644 --- a/tests/test_subdevice_discovery.py +++ b/tests/test_subdevice_discovery.py @@ -277,6 +277,18 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis # capture instead of the synthetic sessions test_subdevices.py uses. # --------------------------------------------------------------------------- +async def test_flat_probe_priority_puts_live_climate_state_before_cold_metrics( + hass: HomeAssistant, +): + """Registry metadata drives fallback order without a model-specific list.""" + resources, _oic_res, _seeds = _load_device_full("airconditioner_fac_bora_205_flat") + coordinator = _coordinator(hass) + + priority = coordinator._subdevice_probe_priority(resources) + + assert "/mode/vs/0" in priority[:4] + assert priority.index("/mode/vs/0") < priority.index("/energy/consumption/vs/0") + async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back( hass: HomeAssistant, diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index 1ee6690..56ae197 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -10,6 +10,7 @@ from typing import cast import cbor2 +from custom_components.localthings.registry import subdevices as subdevices_module from custom_components.localthings.registry.by_type import DeviceRegistry from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc @@ -350,6 +351,116 @@ def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried(): assert probes[f"/{_UUID}/mode/vs/0"] is True +def test_enumeration_budget_bounds_silent_prefixed_fallback_and_uses_priority( + monkeypatch, +): + """A firmware that drops unknown prefixed paths cannot stall setup. + + The Collection probe gets the larger blockwise allowance. The preferred + live-state href is then attempted first, and the last Property probe is + clamped to exactly the time left in the shared enumeration budget. + """ + class Clock: + now = 0.0 + + def monotonic(self): + return self.now + + class SilentSession: + def __init__(self, clock): + self.clock = clock + self.calls = [] + + def get(self, path, timeout=10.0): + self.calls.append((tuple(path), timeout)) + self.clock.now += timeout + raise TimeoutError + + def pace(self): + pass + + clock = Clock() + session = SilentSession(clock) + monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic) + resources = { + '/subdevices/vs/0': { + 'x.com.samsung.da.subdeviceIdList': [_UUID], + }, + '/power/vs/0': {'power': 'On'}, + '/mode/vs/0': {'mode': 'Cool'}, + } + + found, extra = enumerate_subdevices( + session, + resources, + oic_res_links=[], + preferred_hrefs=('/mode/vs/0',), + time_budget=7.0, + collection_timeout=4.0, + property_timeout=2.0, + ) + + assert found == [] + assert extra == {} + assert clock.now == 7.0 + assert session.calls == [ + ((_UUID, 'device', '0'), 4.0), + ((_UUID, 'mode', 'vs', '0'), 2.0), + ((_UUID, 'power', 'vs', '0'), 1.0), + ] + + +def test_enumeration_keeps_preferred_response_found_before_budget_expires( + monkeypatch, +): + """A useful early response survives later silent probes hitting the cap.""" + class Clock: + now = 0.0 + + def monotonic(self): + return self.now + + class PartlyResponsiveSession: + def __init__(self, clock): + self.clock = clock + + def get(self, path, timeout=10.0): + if tuple(path) == (_UUID, 'mode', 'vs', '0'): + return 0x45, cbor2.dumps({'mode': 'Cool'}) + self.clock.now += timeout + raise TimeoutError + + def pace(self): + pass + + clock = Clock() + monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic) + resources = { + '/subdevices/vs/0': { + 'x.com.samsung.da.subdeviceIdList': [_UUID], + }, + '/power/vs/0': {'power': 'On'}, + '/mode/vs/0': {'mode': 'Cool'}, + } + + found, extra = enumerate_subdevices( + PartlyResponsiveSession(clock), + resources, + oic_res_links=[], + preferred_hrefs=('/mode/vs/0',), + time_budget=7.0, + collection_timeout=4.0, + property_timeout=2.0, + ) + + assert [(subdevice.kind, subdevice.key) for subdevice in found] == [ + ('prefixed', _UUID), + ] + assert found[0].flat_hrefs == ('/mode/vs/0',) + assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'Cool'}} + assert clock.now == 7.0 + + def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op(): """The master itself having nothing but /subdevices/vs/0 in its own resources this cycle (e.g. a very first, mostly-empty poll) must not diff --git a/tests/test_translations.py b/tests/test_translations.py index 1191576..b885217 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -114,6 +114,93 @@ def test_no_catalog_carries_unresolved_core_references(): assert unresolved == [], language +def test_confirmed_korean_table_02_washer_course_names(): + states = _load('en')['entity']['select']['washer_cycle_table_02']['state'] + assert {code: states[code] for code in ( + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + )} == { + '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', + '88': 'Pet Care', + } + + +def test_confirmed_dishwasher_course_names(): + states = _load('en')['entity']['select']['dishwasher_cycle']['state'] + assert {code: states[code] for code in ( + '82', '8a', 'a7', 'a8', '8c', '88', + )} == { + '82': 'Auto', + '8a': 'Normal', + 'a7': 'Heavy', + 'a8': 'Express', + '8c': 'Extra Silence', + '88': 'Self Clean', + } + + +def test_confirmed_course_names_are_localized(): + washer_codes = ( + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + ) + dishwasher_codes = ('82', '8a', 'a7', 'a8', '8c', '88') + expected = { + 'cs': { + 'washer': ( + 'AI praní', 'Vlna', 'Džíny', 'Halenky', 'Jemné prádlo', + 'Sportovní oblečení', 'Ložní prádlo', 'Ručníky', + 'Rychlé praní', 'Košile', 'Dezinfekce', 'Čištění bubnu', + 'Outdoor', 'Dětské potřeby', 'Bavlna', + 'Máchání + odstřeďování', 'Pouze odstřeďování', + 'Péče o domácí mazlíčky', + ), + 'dishwasher': ( + 'Automatický', 'Normální', 'Intenzivní', 'Expresní', + 'Extra tichý', 'Samočištění', + ), + }, + 'nl': { + 'washer': ( + 'AI Wash', 'Wol', 'Spijkergoed', 'Blouses', 'Fijne was', + 'Sportkleding', 'Beddengoed', 'Handdoeken', 'Snelle was', + 'Overhemden', 'Hygiëne', 'Trommel reinigen', 'Outdoor', + 'Babyverzorging', 'Katoen', 'Spoelen + centrifugeren', + 'Alleen centrifugeren', 'Pet Care', + ), + 'dishwasher': ( + 'Auto', 'Normaal', 'Intensief', 'Express', 'Extra stil', + 'Zelfreiniging', + ), + }, + } + + for language, translations in expected.items(): + catalog = _load(language)['entity']['select'] + washer = catalog['washer_cycle_table_02']['state'] + dishwasher = catalog['dishwasher_cycle']['state'] + assert tuple(washer[code] for code in washer_codes) == translations['washer'] + assert tuple(dishwasher[code] for code in dishwasher_codes) == ( + translations['dishwasher'] + ) + + # The hood fan is its device's primary feature: fan.py sets _attr_name = None # so it presents as the device itself, and never reads a catalog name. Same # for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 2582ed1..f4f1df3 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -120,6 +120,16 @@ class TestWasherCourse: rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_1C", "GMT_04"]} assert desc.rep_fn(rep) == "1C" + def test_reported_table_02_course_codes_are_translated(self): + """The owner confirmed this washer's newer Table_02 course family.""" + from custom_components.localthings.catalog import translated_states + + confirmed = { + '69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71', + '72', '73', '74', '75', '76', '77', '78', '79', '88', + } + assert confirmed <= translated_states('select', 'washer_cycle_table_02') + def test_missing_course_option_returns_none(self): desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") assert desc.rep_fn is not None @@ -132,6 +142,7 @@ class TestWasherCourse: if e.key == "cycle" and isinstance(e, SelectDesc) ) assert desc.options is laundry.cycle_options + assert desc.display_fn is laundry.washer_cycle_fallback def test_exists_only_when_edit_course_list_is_live(self): """No hardcoded course table is kept -- the selector only appears