Merge pull request #333 from mbillow/claude/merge-prs-251-275-312-q2z9kz

Merge #251, #275, #312: washer/dishwasher/dryer course codes + German translations
This commit is contained in:
Marc Billow
2026-08-08 20:26:58 -04:00
committed by GitHub
20 changed files with 2357 additions and 53 deletions
@@ -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,40 @@ 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 +716,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
@@ -323,12 +323,57 @@ 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 a personal washer course from its device-provided name.
No fallback for an unrecognized standard code -- an invented English
label would defeat translation (PR #251 review); the raw code displays
instead, same as before this function existed.
"""
if not isinstance(value, str):
return None
return personal_course_labels(resources).get(value.upper())
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,14 @@ 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; a code not confirmed by
# the owner or device metadata falls back to washer_cycle_fallback, which
# surfaces a personal-course name only -- no invented English label for an
# unrecognized standard code (PR #251 review).
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
@@ -250,6 +259,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,29 @@ 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 +266,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 +285,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 +305,30 @@ 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 +343,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 +357,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 +373,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 +408,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 +431,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 +449,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 +461,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
+25 -9
View File
@@ -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,15 @@ 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 = 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)
@@ -106,17 +122,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,16 @@
"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í",
"85": "Jemné",
"0c": "Express",
"0d": "Samočištění"
}
},
"dispense_type": {
@@ -339,7 +348,9 @@
"4c": "Osvěžení vzduchem",
"51": "Eko bavlna",
"53": "AI sušení+",
"4e": "Automatické sušení"
"4e": "Automatické sušení",
"26": "Provětrání",
"2a": "Hygienické sušení+"
}
},
"favorite_capacity": {
@@ -621,14 +632,33 @@
"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"
"96": "Méně mikrovláken",
"35": "Eko bavlna"
}
},
"washer_dry_level": {
File diff suppressed because it is too large Load Diff
@@ -296,8 +296,17 @@
"0e": "AI Wash",
"07": "Pre blast",
"8d": "Pots and pans",
"85": "Delicate",
"0c": "Express",
"0d": "Self clean",
"8e": "Plastic",
"8f": "Baby Care"
"8f": "Baby Care",
"82": "Auto",
"8a": "Normal",
"a7": "Heavy",
"a8": "Express",
"8c": "Extra Silence",
"88": "Self Clean"
}
},
"dispense_type": {
@@ -327,8 +336,10 @@
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"26": "Air wash",
"27": "Time dry",
"29": "AI Dry",
"2a": "Hygiene Care+",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
@@ -607,6 +618,7 @@
"32": "Shirts",
"33": "Bedding",
"34": "Mixed",
"35": "E Cotton",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
@@ -621,12 +633,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,16 @@
"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",
"85": "Delicaat",
"0c": "Express",
"0d": "Zelfreiniging"
}
},
"dispense_type": {
@@ -339,7 +348,9 @@
"4c": "Opfrissen",
"51": "Eco katoen",
"53": "AI drogen+",
"4e": "Zelf drogen"
"4e": "Zelf drogen",
"26": "Luchtverfrissing",
"2a": "Hygiënische verzorging+"
}
},
"favorite_capacity": {
@@ -621,14 +632,33 @@
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"69": "AI wassen",
"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": "Huisdierverzorging",
"8f": "Intensief koud",
"96": "Minder microvezels"
"96": "Minder microvezels",
"35": "Eco katoen"
}
},
"washer_dry_level": {
+49
View File
@@ -59,6 +59,55 @@ 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_only_labels_personal_courses(self):
"""No invented English label for an unrecognized code (PR #251 review)."""
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) is None
assert laundry.washer_cycle_fallback("6f", resources) is None
assert laundry.washer_cycle_fallback("Normal", resources) is None
class TestCourseCodesFromSupportedOptions:
"""cycle_options()'s fallback for boards that populate
+4
View File
@@ -46,3 +46,7 @@ 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)")
+80
View File
@@ -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"]
+13
View File
@@ -278,6 +278,19 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
# ---------------------------------------------------------------------------
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,
):
+113
View File
@@ -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,118 @@ 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
+161
View File
@@ -114,6 +114,167 @@ 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 wassen",
"Wol",
"Spijkergoed",
"Blouses",
"Fijne was",
"Sportkleding",
"Beddengoed",
"Handdoeken",
"Snelle was",
"Overhemden",
"Hygiëne",
"Trommel reinigen",
"Outdoor",
"Babyverzorging",
"Katoen",
"Spoelen + centrifugeren",
"Alleen centrifugeren",
"Huisdierverzorging",
),
"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
+27
View File
@@ -120,6 +120,32 @@ 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 +158,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