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:
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user