From f45bd6a72c7d80060dbaf2e785133a9801c499dc Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 9 Aug 2026 21:21:10 +0000 Subject: [PATCH] laundry: discover and offer cloud "Download" cycles (issue #342) A washer whose course table includes "Download"/"Downloaded" runs whichever program the SmartThings cloud last pushed down. Those programs are now selectable from the ordinary cycle select, so a downloaded Jeans or Sports cycle can be started without giving the appliance internet access. The device turns out to enumerate them itself. `CloudExtraCourse_` on /course/vs/0 lists one byte per downloaded program, and byte 2 of a program's payload is exactly that slot id -- verified against all nine programs on the reporter's WW5000C and against the WA55A7700AV dump already in the corpus. So nothing here is hardcoded: the appliance says which programs exist, the payloads are learned by watching what it reports, and the names come from the user. That last part is unavoidable rather than a shortcut. A payload is only visible while its program is loaded, and the appliance never reports a name for one. So cloudcourse.py persists what has been seen (same rationale as learned.py's mode store), a Repairs issue tells the owner how many programs are still unaccounted for, and an options-flow step collects the names. A program appears in the cycle select only once it is both learned and named. Selecting one issues the only two-token options write in the codebase -- the course token has to switch to Download in the same write, or the appliance accepts the program token and silently ignores it (confirmed on hardware). The Download course code is learned by observation but never applied until the user confirms it: tokens in this array are replaced by prefix and never evicted, so a stale program token can appear alongside an unrelated course, and acting on that would start the wrong wash cycle. For the same reason a stale token is never reported as the running program. Also of note: - There is no single "Download" course code. The WW5000C uses 87, the WA55A7700AV uses 17 -- same Table_02. Any per-table lookup would have been wrong on one of the only two devices available to check. - Payloads are replayed byte-for-byte and never decomposed or rebuilt. Bytes 5/7/9 do decode to temperature/rinse/spin on the WW5000C, 9 for 9, and produce nonsense on the WA55A7700AV -- so that decode is written up in docs/investigations/download-cycle.md and not shipped, and the read-only sensors it would have enabled were dropped. - The store reaches the registry as a namespaced synthetic field merged onto /course/vs/0's rep at read time, so exists_fn/rep_fn/options/write_fn all see it through their existing signatures. It never enters the state cache, so it can't be polled over, written to the device, or land in a diagnostics dump. - A name that would render identically to another cycle in the same dropdown is rejected in the flow: the select maps a chosen label back to a raw value by matching display text. Non-English catalogs carry the new strings in English for now; they need real translations. --- custom_components/localthings/catalog.py | 18 + custom_components/localthings/cloudcourse.py | 312 ++++++++++++ custom_components/localthings/config_flow.py | 141 +++++- custom_components/localthings/const.py | 9 + custom_components/localthings/coordinator.py | 139 +++++- .../registry/capabilities/laundry.py | 135 +++++- .../localthings/translations/cs.json | 15 +- .../localthings/translations/de.json | 15 +- .../localthings/translations/en.json | 15 +- .../localthings/translations/es.json | 15 +- .../localthings/translations/it.json | 15 +- .../localthings/translations/ko.json | 15 +- .../localthings/translations/nl.json | 15 +- docs/investigations/download-cycle.md | 159 ++++++ .../fixtures/golden/washer_ww5000c_cloud.json | 34 ++ .../fixtures/washer_ww5000c_cloud_device.json | 452 ++++++++++++++++++ tests/test_cloud_courses.py | 312 ++++++++++++ tests/test_cloud_courses_flow.py | 322 +++++++++++++ tests/test_dishwasher_capabilities.py | 5 +- tests/test_dryer_capabilities.py | 5 +- tests/test_golden_regression.py | 23 + tests/test_laundry_capabilities.py | 5 +- tests/test_washer_capabilities.py | 18 +- 23 files changed, 2162 insertions(+), 32 deletions(-) create mode 100644 custom_components/localthings/cloudcourse.py create mode 100644 docs/investigations/download-cycle.md create mode 100644 tests/fixtures/golden/washer_ww5000c_cloud.json create mode 100644 tests/fixtures/washer_ww5000c_cloud_device.json create mode 100644 tests/test_cloud_courses.py create mode 100644 tests/test_cloud_courses_flow.py diff --git a/custom_components/localthings/catalog.py b/custom_components/localthings/catalog.py index 68b6208..9020e57 100644 --- a/custom_components/localthings/catalog.py +++ b/custom_components/localthings/catalog.py @@ -48,3 +48,21 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]: """ entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key) return frozenset(entry.get("state", ())) if entry else frozenset() + + +def translated_state_labels(platform: str, translation_key: str) -> dict[str, str]: + """`state key -> English label` for `platform`.`translation_key`. + + The labels behind translated_states, for the one caller that has to + compare against what a user actually reads rather than which codes are + translated: the download-cycle naming step rejects a name that would be + indistinguishable from a local course in the same dropdown. English only, + matching this catalog -- a name unique here can still collide in another + locale, which the select's local-courses-first ordering resolves toward + the local course. + """ + entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key) + states = entry.get("state") if entry else None + if not isinstance(states, dict): + return {} + return {code: label for code, label in states.items() if isinstance(label, str)} diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py new file mode 100644 index 0000000..3780b18 --- /dev/null +++ b/custom_components/localthings/cloudcourse.py @@ -0,0 +1,312 @@ +"""Cloud "Download" programs on a laundry device (issue #342). + +Some course tables carry a course whose recipe isn't fixed in firmware -- +selecting it runs whichever program was last pushed down from the +SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three +tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it: + + ``CloudExtraCourse_...`` the device's own list of downloaded + program slots, one byte each -- the cloud counterpart of + ``EditCourseList_`` for local courses. + ``CloudCourse_`` the persisted default program. + ``OneTimeCloudCourse_`` a this-run-only override. + +A ```` is an opaque fixed-width payload whose byte 2 is the slot id +it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers: +the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A`` +enumerates nine slots matching byte 2 of all nine of its programs exactly, +and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot +``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte +2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one +reason nothing here ever synthesizes one. + +What this module does and deliberately does not do +-------------------------------------------------- +The device advertises *which* slots exist but never what any of them is +called, and never the full blob for a slot other than the one currently +loaded. A blob is only observable while the device happens to be sitting on +that program, so the full payload is *learned by observation* and persisted +(same rationale as learned.py's mode store), and the human-readable name is +supplied by the user in the options flow. Nothing is hardcoded: no catalog +of program ids, no table of blobs, no assumed Download course code. A +hardcoded catalog was considered and rejected -- a blob is cloud-assigned +per account/region, so one user's captured payload is not evidence about +anyone else's device. + +Blobs are replayed byte-for-byte, exactly as captured, and never +decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode +cleanly to that program's temperature/rinse/spin, but the same offsets +produce nonsense against the WA55A7700AV blob, so that decode is recorded +in docs/investigations/download-cycle.md rather than shipped.) +""" + +from __future__ import annotations + +import threading + +from .const import CONF_CLOUD_COURSES + +COURSE_HREF = "/course/vs/0" + +EXTRA_PREFIX = "CloudExtraCourse" +DEFAULT_PREFIX = "CloudCourse" +ONESHOT_PREFIX = "OneTimeCloudCourse" +COURSE_PREFIX = "Course" + +# Synthetic, integration-owned field the coordinator merges onto +# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all +# reach this store through their existing signatures -- rep_fn in particular +# receives only its own href's rep, never the resource snapshot, so a +# sibling-resource lookup isn't available to it. Namespaced away from +# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one, +# and merged at read time only: it is never written to the state cache, never +# sent to the device, and never part of a diagnostics dump. +FIELD = "x.localthings.cloudCourses" + +# Raw-value namespace for a cloud program in the cycle select. A slot id is +# itself two hex chars, exactly like a local course code, so the two would be +# indistinguishable (and could collide outright) as bare select values. +RAW_PREFIX = "cloud:" + +# A blob whose first two bytes are FFFF means "no program loaded" rather than +# naming one -- WA55A7700AV reports +# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a +# perfectly ordinary local course, and its byte 2 (01) is not one of the slots +# its own CloudExtraCourse_ advertises. +_SENTINEL_PREFIX = "FFFF" + +# Byte offset within a blob that carries its slot id. +_SLOT_BYTE = 2 +_MIN_BLOB_BYTES = 4 + + +def _hex_bytes(blob): + if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2: + return [] + try: + int(blob, 16) + except ValueError: + return [] + return [blob[i : i + 2].upper() for i in range(0, len(blob), 2)] + + +def is_loaded(blob) -> bool: + """True when `blob` names an actual program rather than 'none'.""" + parts = _hex_bytes(blob) + return bool(parts) and not blob.upper().startswith(_SENTINEL_PREFIX) + + +def slot_of(blob) -> str | None: + """The slot id `blob` belongs to, or None if it names no program.""" + parts = _hex_bytes(blob) + if not parts or not is_loaded(blob): + return None + return parts[_SLOT_BYTE] + + +def option_value(options, prefix): + """`_` from an options[] array. Duplicated from + laundry.option_value rather than imported: this module is imported by + the coordinator, and reaching into registry.capabilities from there + would invert the dependency direction the rest of the integration + keeps.""" + for o in options or []: + if isinstance(o, str) and o.startswith(prefix + "_"): + return o.split("_", 1)[1] + return None + + +def advertised_slots(rep) -> list[str]: + """Slot ids this device says it has downloaded programs in, from its own + CloudExtraCourse_ token. The authority on *which* programs exist -- this + is never inferred from what has been learned so far, so "3 of 9 + discovered" is answerable.""" + raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX) + if not isinstance(raw, str) or len(raw) % 2: + return [] + slots = [raw[i : i + 2].upper() for i in range(0, len(raw), 2)] + # Preserve the device's own order (first-seen wins) while dropping any + # repeat, so the flow lists slots the way the appliance does. + return list(dict.fromkeys(slots)) + + +def supports_cloud_courses(rep) -> bool: + """True for a device that advertises any downloaded-program slot.""" + return bool(advertised_slots(rep)) + + +def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]: + """Restore the persisted record, dropping anything not the shape this + module writes -- it round-trips through the config entry as plain JSON + and a hand-edited .storage file must not be able to crash setup (same + posture as learned._coerce).""" + if not isinstance(stored, dict): + return None, {} + download = stored.get("download_course") + if not isinstance(download, str) or not download: + download = None + slots: dict[str, dict[str, str]] = {} + raw_slots = stored.get("slots") + if isinstance(raw_slots, dict): + for slot, record in raw_slots.items(): + if not isinstance(slot, str) or not isinstance(record, dict): + continue + blob = record.get("blob") + if not is_loaded(blob) or slot_of(blob) != slot.upper(): + continue + name = record.get("name") + slots[slot.upper()] = { + "blob": blob.upper(), + "name": name if isinstance(name, str) and name.strip() else "", + } + return download, slots + + +def stored(entry) -> dict: + """What `entry` has persisted, coerced -- for a reader with no + coordinator to go through (the options flow, on an unloaded entry).""" + download, slots = _coerce(entry.data.get(CONF_CLOUD_COURSES)) + return {"download_course": download, "slots": slots} + + +def persist(hass, entry, record: dict) -> None: + """Write `record` onto the entry. Runs on the event loop, which + async_update_entry requires.""" + hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record}) + + +class CloudCourses: + """Per-device store of discovered cloud programs. + + Mutated from whichever thread applied the update (the DTLS reader for an + OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply), + so every access takes the lock; persistence is the caller's job, on the + event loop. + """ + + def __init__(self, stored_record=None) -> None: + self._lock = threading.Lock() + download, slots = _coerce(stored_record) + self._download_course = download + self._slots = slots + # Course codes seen while a one-time override was actually loaded -- + # candidates for "which course means Download on this board", pending + # user confirmation (see download_candidates). + self._candidates: dict[str, int] = {} + + # -- learning --------------------------------------------------------- + + def observe(self, rep: dict) -> bool: + """Learn from one applied /course/vs/0 rep; True if anything changed. + + Two facts are learnable here. A blob is recorded against the slot its + own byte 2 names, so a program only has to be sitting loaded once -- + on either token -- to be replayable forever after. The Download course + code is only ever taken as a *candidate*: tokens in this array are + replaced by prefix and never evicted, so a stale + OneTimeCloudCourse_ can outlive the run it belonged to and be + reported alongside an unrelated local course. Confirming the code is + the user's call in the options flow -- a wrong one would write a real + wash cycle when someone picked a download program. + """ + options = rep.get("x.com.samsung.da.options") + if not options: + return False + known_slots = advertised_slots(rep) + changed = False + with self._lock: + for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX): + blob = option_value(options, prefix) + slot = slot_of(blob) + # A blob whose slot the device doesn't advertise is not a + # program this appliance offers -- don't record it. + if slot is None or (known_slots and slot not in known_slots): + continue + record = self._slots.get(slot) + if record is None: + self._slots[slot] = {"blob": blob.upper(), "name": ""} + changed = True + elif record["blob"] != blob.upper(): + record["blob"] = blob.upper() + changed = True + oneshot = option_value(options, ONESHOT_PREFIX) + course = option_value(options, COURSE_PREFIX) + if course and is_loaded(oneshot): + self._candidates[course] = self._candidates.get(course, 0) + 1 + return changed + + # -- reads ------------------------------------------------------------ + + def download_course(self) -> str | None: + with self._lock: + return self._download_course + + def download_candidates(self) -> list[str]: + """Course codes seen alongside a loaded one-time override, most + frequent first -- what the options flow offers as the likely Download + course. Never used for a write on its own.""" + with self._lock: + ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0])) + return [code for code, _ in ranked] + + def blob(self, slot: str) -> str | None: + with self._lock: + record = self._slots.get(slot.upper()) + return record["blob"] if record else None + + def named(self) -> dict[str, str]: + """Slots that are both learned and named -- the only ones offerable + as a cycle option. An unnamed slot has no label that isn't either + invented or an opaque hex id, so it stays out of the UI until the + user supplies one.""" + with self._lock: + return {slot: record["name"] for slot, record in self._slots.items() if record["name"]} + + def snapshot(self) -> dict: + with self._lock: + return { + "download_course": self._download_course, + "slots": {slot: dict(record) for slot, record in self._slots.items()}, + } + + def view(self) -> dict: + """What the registry sees under FIELD: only what a write or a label + can actually be built from, so a descriptor never has to re-apply + this module's rules.""" + with self._lock: + if not self._download_course: + return {} + return { + "download_course": self._download_course, + "programs": { + slot: {"blob": record["blob"], "name": record["name"]} + for slot, record in self._slots.items() + if record["name"] + }, + } + + # -- writes ----------------------------------------------------------- + + def set_download_course(self, code: str | None) -> None: + with self._lock: + self._download_course = code or None + + def set_name(self, slot: str, name: str) -> None: + with self._lock: + record = self._slots.get(slot.upper()) + if record is not None: + record["name"] = name.strip() + + def clear(self) -> None: + with self._lock: + self._download_course = None + self._slots = {} + self._candidates = {} + + +def undiscovered(rep: dict, record: dict) -> list[str]: + """Advertised slots that aren't yet usable -- unlearned or unnamed. What + the Repairs issue counts, and what the options flow asks the user to walk + the appliance through.""" + programs = record.get("slots") or {} + return [slot for slot in advertised_slots(rep) if not (programs.get(slot) or {}).get("name")] diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 97c76fb..8f8b519 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -34,6 +34,8 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) +from . import cloudcourse +from .catalog import translated_state_labels from .const import ( CLIENTHELLO_PROBE_RETRIES, CLIENTHELLO_PROBE_TIMEOUT_S, @@ -62,6 +64,8 @@ from .const import ( ) from .learned import persist as learned_persist from .learned import stored as learned_stored +from .registry.capabilities.laundry import cycle_options +from .registry.subdevices import MAIN _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) _MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)) @@ -832,10 +836,14 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id) async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - return self.async_show_menu( - step_id="init", - menu_options=["settings", "forget_learned_modes", "debug_write"], - ) + menu = ["settings", "forget_learned_modes", "debug_write"] + # Only offered on an appliance that actually advertises downloaded + # programs (issue #342) -- every other device would get a menu entry + # leading to an empty screen. + coord = self._coordinator() + if coord is not None and cloudcourse.supports_cloud_courses(coord.cloud_course_rep()): + menu.insert(1, "cloud_courses") + return self.async_show_menu(step_id="init", menu_options=menu) async def async_step_settings( self, user_input: dict[str, Any] | None = None @@ -901,6 +909,131 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): description_placeholders={"codes": ", ".join(codes) if codes else "(none)"}, ) + async def async_step_cloud_courses( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Name the cloud "Download" programs this appliance has (issue #342). + + The device advertises how many downloaded programs it holds but never + what any of them is called, and only ever exposes the replay payload + for the one currently loaded. So this screen can only offer the ones + already seen loaded, and asks the user for the names -- the appliance + has no name to give and inventing one is not an option (the same rule + that governs unrecognized local course codes). + + Also confirms which course code means Download. It is auto-detected + by observation, but never used for a write until confirmed here: the + options array replaces tokens by prefix and never evicts them, so a + stale program token can be reported alongside an unrelated course and + make an ordinary wash cycle look like the Download one. Writing the + wrong code would start the wrong cycle. + """ + coord = self._coordinator() + if coord is None: + return self.async_abort(reason="not_loaded") + + store = coord.cloud_courses + rep = coord.cloud_course_rep() + record = store.snapshot() + slots = record["slots"] + advertised = cloudcourse.advertised_slots(rep) + # Learned slots keep the appliance's own ordering; anything learned + # but no longer advertised still gets a row so a name isn't stranded. + known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised] + + if user_input is not None: + errors = self._apply_cloud_course_names(coord, known, user_input) + if not errors: + return self.async_create_entry(data=dict(self.config_entry.options)) + return self._cloud_courses_form(coord, rep, known, advertised, errors=errors) + + return self._cloud_courses_form(coord, rep, known, advertised) + + def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: + """Validate and store the submitted names + Download course code. + + A name that collides with any other entry in the cycle select -- + another download cycle, or one of the appliance's own local course + names -- is rejected rather than silently accepted. The select maps a + chosen label back to a raw value by matching display text, so two + options sharing a label would resolve to whichever comes first. + """ + names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known} + taken = {name.casefold() for name in self._local_course_names(coord)} + for name in names.values(): + if not name: + continue + if name.casefold() in taken: + return {"base": "cloud_course_name_duplicate"} + taken.add(name.casefold()) + + for slot, name in names.items(): + coord.cloud_courses.set_name(slot, name) + coord.set_cloud_download_course(user_input.get("download_course") or None) + return {} + + def _local_course_names(self, coord) -> set[str]: + """Translated display names of this appliance's own local courses. + + Read through the same catalog the select renders from, so the check + matches what the user will actually see side by side in the dropdown. + An untranslated code has no display name to collide with. + """ + bound = next( + (b for b in coord.bound if b.desc.key == "cycle" and b.href == cloudcourse.COURSE_HREF), + None, + ) + if bound is None: + return set() + resources = coord.canonical_resources(bound.subdevice) + key = bound.desc.translation_key + if callable(key): + key = key(resources) + labels = translated_state_labels("select", key) if key else {} + return {labels[code.lower()] for code in cycle_options(resources) if code.lower() in labels} + + def _cloud_courses_form( + self, coord, rep, known, advertised, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + store = coord.cloud_courses + record = store.snapshot() + slots = record["slots"] + + fields: dict[Any, Any] = {} + for slot in known: + fields[vol.Optional(f"name_{slot}", default=slots.get(slot, {}).get("name", ""))] = ( + _TEXT + ) + + # Course codes this device actually offers, so the Download course + # can only ever be set to one of them. Auto-detected candidates come + # first -- see CloudCourses.download_candidates. + available = cycle_options(coord.canonical_resources(MAIN)) + candidates = [c for c in store.download_candidates() if c in available] + ordered = candidates + [c for c in available if c not in candidates] + suggested = record["download_course"] or (candidates[0] if candidates else None) + fields[vol.Optional("download_course", description={"suggested_value": suggested})] = ( + SelectSelector( + SelectSelectorConfig( + options=ordered, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + ) + + pending = [s for s in advertised if s not in slots] + return self.async_show_form( + step_id="cloud_courses", + data_schema=vol.Schema(fields), + errors=errors or {}, + description_placeholders={ + "found": str(len(known)), + "total": str(len(advertised)) if advertised else str(len(known)), + "pending": ", ".join(pending) if pending else "(none)", + }, + ) + async def async_step_debug_write( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 3e62fcc..3603e0b 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -48,6 +48,15 @@ CONF_LEARNED_MODES = "learned_modes" CONF_LEARN_MODES = "learn_device_modes" DEFAULT_LEARN_MODES = True +# entry.data key: cloud "Download" programs discovered on a laundry device +# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full +# replay payload is only ever visible while the device happens to be sitting +# on it, so it has to survive a restart -- but a richer shape, because a +# cloud program also needs a user-supplied name and the device's own +# Download course code. See cloudcourse.py, which owns the shape. Shape: +# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}} +CONF_CLOUD_COURSES = "cloud_courses" + # Options-flow key (entry.options, not entry.data): lets a user override # the device-wide remote-control-off write block for a specific device # (issue #54). Some devices accept certain writes even while reporting diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 0e8de00..2977fbe 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -22,8 +22,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from smartthings_local.ocf.state_cache import StateCache from smartthings_local.protocol.dtls_session import DtlsCoapSession +from . import cloudcourse +from .cloudcourse import CloudCourses +from .cloudcourse import persist as cloud_persist from .const import ( CONF_BYPASS_REMOTE_CONTROL, + CONF_CLOUD_COURSES, CONF_DEVICE_TYPE, CONF_HOST, CONF_LEAF_CERT_PEM, @@ -259,6 +263,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # supported (issue #327), restored from the entry so one learned # last week is still offered today. See learned.py. self._learned = LearnedModes(entry.data.get(CONF_LEARNED_MODES)) + # Cloud "Download" programs discovered on this device (issue #342). + # Same restore-from-entry shape as _learned above; see cloudcourse.py. + self._cloud = CloudCourses(entry.data.get(CONF_CLOUD_COURSES)) # Narrowed to this device's own climate hrefs once discovery has # run -- see _refresh_learnable_hrefs. self._learnable_hrefs: set[str] = set() @@ -312,6 +319,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): which copies every tracked href to build the snapshot dict.""" return self._cache.get(href) or {} + def entity_resources(self) -> dict[str, dict]: + """The live snapshot as entity descriptors should see it: the device's + own reps, plus this integration's discovered cloud programs merged + onto /course/vs/0 under cloudcourse.FIELD (issue #342). + + Merged at read time rather than applied to the state cache, so the + synthetic field can never be polled over, written to the device, or + reach a diagnostics dump -- `last_resources` stays exactly what the + appliance reported. It rides on the rep instead of a resource of its + own because rep_fn receives only its own href's rep: a sibling href + would be invisible to it, and /course/vs/0 is the one resource every + consumer of this data is already bound to. + """ + snapshot = self._cache.snapshot() + rep = snapshot.get(cloudcourse.COURSE_HREF) + if rep is None: + return snapshot + view = self._cloud.view() + if not view: + return snapshot + snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view} + return snapshot + def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]: """`subdevice`'s view of the live snapshot, rewritten to canonical hrefs (issue #177, see subdevices.canonical_view). Any platform @@ -325,7 +355,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): cached = self._canonical_cache.get(view_key) if cached is not None: return cached - view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices) + view = canonical_view(subdevice, self.entity_resources(), self.subdevices) self._canonical_cache[view_key] = view return view @@ -389,7 +419,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): An 'optimistic' rep is the value this integration just wrote, not one the device reported, so there is nothing to learn from it.""" - if source == "optimistic" or href not in self._learnable_hrefs: + if source == "optimistic": + return + if href == cloudcourse.COURSE_HREF: + self._observe_cloud_courses(rep) + if href not in self._learnable_hrefs: return if not self.learning_enabled: return @@ -406,6 +440,85 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _persist_learned(self) -> None: persist(self.hass, self._entry, self._learned.snapshot()) + # ------------------------------------------------------------------ + # Cloud "Download" programs (issue #342) -- see cloudcourse.py + # ------------------------------------------------------------------ + + def _observe_cloud_courses(self, rep: dict) -> None: + """Learn a downloaded program's replay payload from one applied + /course/vs/0 rep. Runs on whichever thread applied the update, so + both the persist and the Repairs refresh go through hass.add_job. + + Unlike learned modes this has no opt-out option: it records only + what the appliance itself reports about programs it itself + advertises, and nothing is offered in the UI until the user names it. + """ + if not self._cloud.observe(rep): + return + self.hass.add_job(self._persist_cloud_courses) + + @callback + def _persist_cloud_courses(self) -> None: + cloud_persist(self.hass, self._entry, self._cloud.snapshot()) + # A newly learned program changes what entity_resources() hands out. + self._canonical_cache.clear() + self._refresh_cloud_course_issue() + + @property + def cloud_courses(self) -> CloudCourses: + """The discovered-program store, for the options flow.""" + return self._cloud + + def cloud_course_rep(self) -> dict: + """/course/vs/0's live rep -- what advertises the slot list.""" + return self.resource(cloudcourse.COURSE_HREF) + + def set_cloud_course_name(self, slot: str, name: str) -> None: + self._cloud.set_name(slot, name) + self._persist_cloud_courses() + + def set_cloud_download_course(self, code: str | None) -> None: + self._cloud.set_download_course(code) + self._persist_cloud_courses() + + def forget_cloud_courses(self) -> None: + self._cloud.clear() + self._persist_cloud_courses() + + @callback + def _refresh_cloud_course_issue(self) -> None: + """Raise or clear the "you have downloaded programs Home Assistant + can't offer yet" Repairs issue (issue #342). + + A downloaded program is only usable once the appliance has been seen + sitting on it (that is the only time its replay payload is visible) + and the user has given it a name. The device advertises how many it + has, so the gap between that and what's usable is knowable -- and + it can only be closed by the user walking the appliance through its + own Download list, which is exactly what a Repair is for. + """ + issue_id = f"cloud_courses_{self._entry.entry_id}" + rep = self.cloud_course_rep() + pending = cloudcourse.undiscovered(rep, self._cloud.snapshot()) + needs_course = bool(cloudcourse.advertised_slots(rep)) and not self._cloud.download_course() + if pending or needs_course: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="cloud_courses_undiscovered", + translation_placeholders={ + "device_name": self.device_info.get("name") or "This appliance", + "pending": str(len(pending)), + "total": str(len(cloudcourse.advertised_slots(rep))), + }, + learn_more_url=DEVICE_SUPPORT_ISSUE_URL, + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + def device_info_for(self, subdevice: Subdevice) -> DeviceInfo: """DeviceInfo for one logical subdevice on this connection (issue #177): the master's own device_info for MAIN, or a linked child @@ -508,7 +621,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): with self._push_pending_lock: self._push_pending = False if self.bound: - self.async_set_updated_data(flatten(self.bound, self._cache.snapshot())) + self.async_set_updated_data(flatten(self.bound, self.entity_resources())) def _poll_once(self) -> dict[str, dict]: """GET /device/0, return parsed resources. Blocking. @@ -1070,7 +1183,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): type(e).__name__, e, ) - return flatten(self.bound, self._cache.snapshot()) + return flatten(self.bound, self.entity_resources()) self._consecutive_poll_timeouts = 0 # A lone reconnect is routine (README's "Known device # behavior"); only warn once they pile up. Pause first so @@ -1099,7 +1212,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # there are bound entities to carry it. if self._discovered and snapshot: self._log.debug("Full error:", exc_info=e2) - return flatten(self.bound, snapshot) + return flatten(self.bound, self.entity_resources()) raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2 else: # A fresh session has zero OBSERVE registrations; if we @@ -1147,6 +1260,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): for href, rep in resources.items(): self._observe.apply(href, rep, source=source) + if first_cycle: + # The apply loop above has just fed this poll's /course/vs/0 to + # the cloud-program store, so the gap is knowable now. Explicit + # rather than left to _persist_cloud_courses: a device whose + # programs are all already named learns nothing on this cycle and + # would otherwise never get its stale Repair cleared. + self._refresh_cloud_course_issue() + if first_cycle or self._resubscribe_due: self._resubscribe_due = False await self._attempt_observe_mode() @@ -1163,7 +1284,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll" ) - return flatten(self.bound, self._cache.snapshot()) + return flatten(self.bound, self.entity_resources()) # ------------------------------------------------------------------ # Command dispatch (called by entity platforms in Task 5) @@ -1185,7 +1306,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if write_fn is None: return href = bound_entity.href - rep = self._cache.get(href or "") or {} + # Through entity_resources(), not the bare cache: write_fn must see + # the same rep exists_fn/rep_fn were handed, including the merged + # cloud-program field (issue #342). Identical to the cache entry for + # every href that field doesn't touch. + rep = self.entity_resources().get(href or "") or {} # The remote-control gate below keys off the raw on-the-wire href # and a raw snapshot -- /remotectrl/* is a shared, MAIN-only # resource that a subdevice's canonical_resources() view (owned diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 4205802..c66618f 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -23,6 +23,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` / from datetime import UTC, datetime from datetime import time as dt_time +from ... import cloudcourse from ...catalog import has_entity_translation from ..capability import Capability from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc @@ -309,20 +310,120 @@ def _course_codes_from_supported_options(course_rep): return [] +def option_tokens(*pairs): + """[(prefix, value), ...] -> ['_', ...] -- the general + form of option_write, for the one write that needs two tokens to land in + the same options[] array together (see cycle_write's cloud branch).""" + return [f"{prefix}_{value}" for prefix, value in pairs] + + def option_write(prefix, new_value): """A one-token x.com.samsung.da.options write -- see the module comment above for why this doesn't read/rewrite the whole array.""" - return [f"{prefix}_{new_value}"] + return option_tokens((prefix, new_value)) -def cycle_write(p, rep, href=None): +# --------------------------------------------------------------------------- +# Cloud "Download" programs, folded into this same cycle select (issue #342). +# +# A device that has downloaded programs advertises them on the same +# /course/vs/0 options array; cloudcourse.py owns the token shapes, the +# learned store, and the reasoning for all of it. Everything below is just +# how that store reaches the select: the coordinator merges it onto this +# href's rep under cloudcourse.FIELD, so the option list, current value, +# label, and write path each read it from the rep or snapshot they already +# receive. +# +# They ride in the cycle select rather than a select of their own because +# that is what they are to a user -- on the appliance's own dial, "Download" +# occupies one position among the ordinary courses, and picking a downloaded +# program is picking a cycle. Their raw values are namespaced +# ('cloud:') so they can never be confused with, or collide with, a +# two-hex-char local course code. +# +# Confirmed on hardware before any of this was written (issue #342): writing +# the program token alone, while some other course is selected, is silently +# ignored -- the course token has to switch to Download in the *same* write. +# Hence the two-token write, the only one in this module. + + +def _cloud_state(rep): + return rep.get(cloudcourse.FIELD) or {} + + +def cloud_options(rep): + """Namespaced raw values for every named, learned cloud program.""" + return [ + f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {})) + ] + + +def cloud_label(value, resources): + """The user's own name for a 'cloud:' value. + + Cloud program names are user-supplied, never translated: the appliance + reports only an opaque slot id, and inventing an English label for one + is exactly what this module refuses to do for unrecognized local course + codes (see washer_cycle_fallback). + """ + if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX): + return None + slot = value[len(cloudcourse.RAW_PREFIX) :] + rep = resources.get(cloudcourse.COURSE_HREF) or {} + program = _cloud_state(rep).get("programs", {}).get(slot) + return program["name"] if program else None + + +def cloud_current(rep): + """'cloud:' when a named cloud program is the live selection. + + Gated on the course actually being this device's confirmed Download + course: tokens in this array are replaced by prefix and never evicted, so + a one-time program token outlives the run it belonged to and would + otherwise report "Jeans" while an ordinary cotton cycle runs. + """ + state = _cloud_state(rep) + download = state.get("download_course") + options = rep.get("x.com.samsung.da.options") + if not download or option_value(options, "Course") != download: + return None + blob = option_value(options, cloudcourse.ONESHOT_PREFIX) + slot = cloudcourse.slot_of(blob) + if slot is None: + # No one-time override loaded: the appliance falls back to whatever + # the persisted default holds (confirmed with the issue #342 + # reporter -- leaving Download and returning to it re-selects the + # saved program, not the last one-time one). + slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX)) + if slot is None or slot not in state.get("programs", {}): + return None + return f"{cloudcourse.RAW_PREFIX}{slot}" + + +def cycle_write(p, rep, href=None, resources=None): if not rep.get("x.com.samsung.da.options"): return None + if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX): + return _cloud_cycle_write(p, rep) return ["course", "vs", "0"], { "x.com.samsung.da.options": option_write("Course", p), } +def _cloud_cycle_write(p, rep): + state = _cloud_state(rep) + download = state.get("download_course") + program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :]) + if not download or program is None: + return None + # Order matches what the appliance was confirmed to accept. + return ["course", "vs", "0"], { + "x.com.samsung.da.options": option_tokens( + ("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"]) + ), + } + + def personal_course_labels(resources, href="/wm/personalcourse/vs/0"): """Return device-provided personal course names keyed by course code. @@ -399,6 +500,11 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None): 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. + + Any cloud "Download" programs the user has discovered and named join the + same option list, after the local courses -- see the cloud section above. + A device with none (or one whose owner hasn't named any yet) gets exactly + the list it got before they existed. """ key = translation_key if table_href is not None: @@ -410,14 +516,31 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None): candidate = f"{translation_key}_{table.lower()}" return candidate if has_entity_translation("select", candidate) else "cycle" + def options(resources): + rep = resources.get("/course/vs/0") or {} + # Local courses first: a user-supplied cloud name that happens to + # match a translated course name resolves back to the real local + # course on write, which is the safer of the two. The options flow + # rejects such a name outright, so this is a backstop, not the fix. + return [*cycle_options(resources), *cloud_options(rep)] + + def current(rep): + return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course") + + def label(value, resources): + cloud = cloud_label(value, resources) + if cloud is not None: + return cloud + return display_fn(value, resources) if display_fn is not None else None + return SelectDesc( key="cycle", icon=icon, translation_key=key, - 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, + options=options, + exists_fn=lambda rep, resources: bool(options(resources)), + rep_fn=current, + display_fn=label, write_fn=cycle_write, ) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 95c2e5f..b6e5db2 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1406,6 +1406,7 @@ "title": "Možnosti LocalThings", "menu_options": { "settings": "Nastavení zápisu pro dálkové ovládání", + "cloud_courses": "Download cycles", "forget_learned_modes": "Zapomenout zapamatované režimy", "debug_write": "Ladění: zápis do prostředku" } @@ -1444,11 +1445,19 @@ "debug_write": "Zapsat do dalšího prostředku", "finish": "Dokončit" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Zadejte alespoň jedno pole k zápisu.", - "write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant." + "write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "Neúplné pokrytí funkcí pro {device_name}", "description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 254f331..f76fcb8 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1406,6 +1406,7 @@ "title": "LocalThings-Optionen", "menu_options": { "settings": "Geräteeinstellungen", + "cloud_courses": "Download cycles", "debug_write": "Debug: In eine Ressource schreiben", "forget_learned_modes": "Gemerkte Modi vergessen" } @@ -1444,11 +1445,19 @@ "forget_learned_modes": { "title": "Gemerkte Modi vergessen", "description": "Aktuell gemerkt: {codes}\n\nDies sind Modi, in denen sich dieses Gerät selbst gemeldet hat, ohne sie als unterstützt anzugeben; sie werden aufbewahrt, damit sie auswählbar bleiben. Das Vergessen ist die Lösung, wenn sich einer davon als falsch herausgestellt hat -- alles, was das Gerät tatsächlich erneut meldet, wird einfach erneut gemerkt, es sei denn, Sie schalten auch „Vom Gerät gemeldete, aber nicht angegebene Modi merken“ in den Geräteeinstellungen aus." + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Geben Sie mindestens ein zu schreibendes Feld ein.", - "write_failed": "Der Schreibvorgang ist fehlgeschlagen. Weitere Details finden Sie im Home-Assistant-Protokoll." + "write_failed": "Der Schreibvorgang ist fehlgeschlagen. Weitere Details finden Sie im Home-Assistant-Protokoll.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "Unvollständige Funktionsabdeckung für {device_name}", "description": "Für dieses Gerät liegt keine vollständige Funktionsabdeckung vor. Entweder wurde sein Gerätetyp nicht erkannt, oder einige der von ihm bereitgestellten Ressourcen sind noch nicht abgebildet. Es funktioniert weiterhin mit dem, was bereits unterstützt wird. Sie können helfen, die Unterstützung zu erweitern, indem Sie zu Einstellungen > Geräte & Dienste > {device_name} > das Menü (oben rechts) > Diagnose herunterladen gehen und diese anschließend über die verlinkte Issue-Vorlage einreichen." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 43ce1f4..16da390 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1406,6 +1406,7 @@ "title": "LocalThings options", "menu_options": { "settings": "Device settings", + "cloud_courses": "Download cycles", "forget_learned_modes": "Forget remembered modes", "debug_write": "Debug: write to a resource" } @@ -1444,11 +1445,19 @@ "debug_write": "Write another resource", "finish": "Finish" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Enter at least one field to write.", - "write_failed": "The write failed. Check the Home Assistant logs for details." + "write_failed": "The write failed. Check the Home Assistant logs for details.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "This device isn't connected yet. Try again once it has loaded." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "Incomplete capability coverage for {device_name}", "description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 6d2576f..226b25c 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -53,6 +53,7 @@ "title": "Opciones de LocalThings", "menu_options": { "settings": "Ajustes del dispositivo", + "cloud_courses": "Download cycles", "forget_learned_modes": "Olvidar los modos recordados", "debug_write": "Depuración: escribir en un recurso" } @@ -91,11 +92,19 @@ "debug_write": "Escribir otro recurso", "finish": "Finalizar" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Introduce al menos un campo para escribir.", - "write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles." + "write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." @@ -105,6 +114,10 @@ "device_gap": { "title": "Cobertura de capacidades incompleta para {device_name}", "description": "A este dispositivo le falta cobertura completa de capacidades. O su tipo de electrodoméstico no fue reconocido, o algunos de los recursos que expone aún no están modelados. Seguirá funcionando con lo que ya está soportado. Puedes ayudar a ampliar el soporte yendo a Ajustes > Dispositivos y servicios > {device_name} > el menú (arriba a la derecha) > Descargar diagnósticos, y reportándolo con la plantilla de incidencia enlazada." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index b9528b1..9735821 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1406,6 +1406,7 @@ "title": "Opzioni LocalThings", "menu_options": { "settings": "Impostazioni dispositivo", + "cloud_courses": "Download cycles", "forget_learned_modes": "Dimentica le modalità memorizzate", "debug_write": "Debug: scrivi su una risorsa" } @@ -1444,11 +1445,19 @@ "debug_write": "Scrivi un'altra risorsa", "finish": "Fine" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Inserisci almeno un campo da scrivere.", - "write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli." + "write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "Copertura delle funzionalità incompleta per {device_name}", "description": "Questo dispositivo non è completamente supportato. Il tipo di dispositivo non è stato riconosciuto oppure alcune delle risorse che espone non sono ancora state modellate. Continuerò a funzionare con le funzionalità già supportate. Puoi contribuire ad ampliare il supporto andando su Impostazioni > Dispositivi e servizi > {device_name} > il menu (in alto a destra) > Scarica diagnostica, quindi inviando una segnalazione tramite il modulo di segnalazione collegato." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 043b1d3..675c2be 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1406,6 +1406,7 @@ "title": "LocalThings 옵션", "menu_options": { "settings": "기기 설정", + "cloud_courses": "Download cycles", "forget_learned_modes": "기억된 모드 지우기", "debug_write": "디버그: 리소스에 쓰기" } @@ -1444,11 +1445,19 @@ "debug_write": "다른 리소스에 쓰기", "finish": "완료" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "쓸 필드를 하나 이상 입력하세요.", - "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요." + "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "{device_name}의 기능 지원이 완전하지 않음", "description": "이 기기의 일부 기능이 아직 완전히 지원되지 않습니다. 가전제품 유형이 인식되지 않았거나, 기기가 제공하는 일부 리소스가 아직 구현되지 않았습니다. 현재 지원되는 기능은 계속 사용할 수 있습니다. 설정 > 기기 및 서비스 > {device_name} > 오른쪽 위 메뉴 > 진단 정보 다운로드로 이동하여 진단 정보를 내려받은 뒤, 연결된 이슈 양식에 첨부하면 지원 범위를 넓히는 데 도움을 줄 수 있습니다." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 542826f..e5b4eb6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1406,6 +1406,7 @@ "title": "LocalThings-opties", "menu_options": { "settings": "Apparaatinstellingen", + "cloud_courses": "Download cycles", "forget_learned_modes": "Onthouden modi vergeten", "debug_write": "Foutopsporing: naar een resource schrijven" } @@ -1444,11 +1445,19 @@ "debug_write": "Naar een andere resource schrijven", "finish": "Voltooien" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "data": { + "download_course": "Download cycle course code" + } } }, "error": { "empty_payload": "Voer ten minste één veld in om te schrijven.", - "write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie." + "write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.", + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." }, "abort": { "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." @@ -1458,6 +1467,10 @@ "device_gap": { "title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}", "description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template." + }, + "cloud_courses_undiscovered": { + "title": "Downloaded cycles not set up for {device_name}", + "description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there." } }, "exceptions": { diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md new file mode 100644 index 0000000..f4c3e1b --- /dev/null +++ b/docs/investigations/download-cycle.md @@ -0,0 +1,159 @@ +# Laundry cloud "Download" cycles: solved, with one dead end + +`registry/capabilities/laundry.py`'s cycle select offers a washer's +downloaded ("Download" / "Downloaded") programs alongside its ordinary +courses now, driven by the store in `cloudcourse.py` (issue #342). This file +records the byte-level work behind it, including a decode that fit one +device perfectly and collapsed on the second — the reason nothing in the +shipped code interprets a program payload at all. + +Two real devices carry these tokens: + +| dump | model | slots advertised | blob width | +| --- | --- | --- | --- | +| `washer_ww5000c_cloud` | WW5000C, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 20 bytes | +| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 16 bytes | + +Both are Table_02. Note that already: same course table, different +everything else. + +## The three tokens + +All on `/course/vs/0`'s `x.com.samsung.da.options` array, same +prefix-match/replace merge as every other token there. + +- `CloudExtraCourse_…` — the device's own list of downloaded + program slots, one byte each. The cloud counterpart of `EditCourseList_`. +- `CloudCourse_` — the persisted default program. +- `OneTimeCloudCourse_` — a this-run-only override. + +### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot + +The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for +its nine downloaded programs. Byte 2 of each of the nine blobs its owner +captured is exactly one of those nine, no repeats, sets equal: + +``` + blob byte2 program + 00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports + 00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only + 00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor + 00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans + 00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet + 00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive + 00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven + 00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts + 00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels + +CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A +``` + +Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its +`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its +`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an +advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather +than naming a program. That sentinel is why `cloudcourse.is_loaded` exists. + +This is what makes "3 of 9 discovered" answerable, and it is why no catalog +of program ids is hardcoded anywhere: the appliance already knows which +programs it has. + +## Writing: the two-token rule + +Confirmed on hardware by the issue #342 reporter. Writing +`OneTimeCloudCourse_` alone while some other course is selected is +accepted at the protocol level (no error) and then silently ignored by the +machine. It takes effect only when the same write also switches `Course_` to +the Download course — which is what `laundry._cloud_cycle_write` does, and +the only two-token options write in the codebase: + +```yaml +x.com.samsung.da.options: + - Course_87 + - OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00 +``` + +`CloudCourse_` was separately confirmed writable on its own: set while on +Download it changes the running program; set from another course it becomes +what gets preselected the next time Download is chosen. The integration +doesn't write it today — a "default download cycle" control is a possible +follow-up, deliberately left out of the first pass. + +### There is no single "Download" course code + +The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17` +("Downloaded" in `washer_cycle_table_02`). **Same course table, different +code.** Any per-table lookup of "the Download code" would have been wrong on +one of the only two devices available to check it against, which is why the +code is learned by observation and confirmed by the user in the options flow +instead of tabled. + +The observation signal is "whatever `Course_` reads while a non-sentinel +`OneTimeCloudCourse_` is loaded." That is a *candidate*, never applied +directly: tokens in this array are replaced by prefix and never evicted, so +a stale program token outlives the run it belonged to and can be reported +next to an unrelated local course. Acting on that unconfirmed would start +the wrong wash cycle. + +## The dead end: bytes 5/7/9 do not decode portably + +With the nine WW5000C programs and their app-reported settings side by side, +three of the varying bytes fit perfectly: + +| byte | meaning | formula | fit | +| --- | --- | --- | --- | +| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 | +| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 | +| 9 | spin level | `(b - 0x90) / 8` | 9/9 | + +Nine for nine, including the internally consistent case: "Spin only" is the +only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that +skips washing entirely while still reporting a spin level. + +It does not survive the second device. Against the WA55A7700AV's +`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`: + +- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C** +- spin: `(0x22 - 0x90) / 8` = **negative** +- rinse: `0x11 - 0x10` = 1 — the only plausible one + +What *does* survive is the tag structure. Bytes `49`, `4D`, `4A`, `4C` sit at +the same offsets on both devices with varying values between them, and the +byte before the run (`04` vs `05`) looks like a field count — the payload is +tag/value, not fixed offsets, and the value *scales* are board- or +region-specific. Decoding it properly needs a third device; one device's fit +is a coincidence-shaped hypothesis, not a format. + +So the shipped code never interprets a payload: a blob is recorded whole and +replayed byte-for-byte, exactly as the device reported it, and never +decomposed or rebuilt. The read-only "loaded program's temperature/spin" +sensors this decode would have enabled were dropped for the same reason. + +## What is deliberately not done + +- **No hardcoded program catalog.** Blobs are cloud-assigned per + account/region. One owner's captured payload is not evidence about anyone + else's appliance, and a table of them would offer options that write + another household's wash settings. +- **No invented names.** The appliance reports an opaque slot id and nothing + else. Names come from the user in the options flow, the same rule that + stops an unrecognized local course code from getting a made-up English + label (PR #251 review). +- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing + builds a payload from parts — the device was never tested with one, and a + fabricated blob is an untested write to a wash cycle. + +## Open questions for the next dump + +1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the + course changes? Behavior suggests the appliance falls back to + `CloudCourse_` when Download is re-entered, but the token's own lifecycle + is unconfirmed. `laundry.cloud_current` is written to be correct either + way. +2. What does byte 1 mean? It is distinct per program and *sometimes* + coincides with a plausible local course code for that program (`2E` Baby + Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and + sometimes doesn't (`21` Colors for "Sports"). Probably a base-course + reference; not reliable enough to use. +3. A third device with downloaded programs would settle the tag/value + reading of the payload. diff --git a/tests/fixtures/golden/washer_ww5000c_cloud.json b/tests/fixtures/golden/washer_ww5000c_cloud.json new file mode 100644 index 0000000..54fa4cd --- /dev/null +++ b/tests/fixtures/golden/washer_ww5000c_cloud.json @@ -0,0 +1,34 @@ +{ + "state_keys": [ + "alarm_code", + "bubble_soak", + "buzzer_sound", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", + "energy_kwh", + "energy_saved_kwh", + "finish_sound", + "finish_time", + "firmware_update", + "intensive", + "job_beginning_status", + "machine_state", + "power_switch", + "pre_wash", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "softener_low", + "spin_speed", + "wash_temperature", + "water_liters" + ] +} diff --git a/tests/fixtures/washer_ww5000c_cloud_device.json b/tests/fixtures/washer_ww5000c_cloud_device.json new file mode 100644 index 0000000..02870e2 --- /dev/null +++ b/tests/fixtures/washer_ww5000c_cloud_device.json @@ -0,0 +1,452 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "74800", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1786287600", + "x.com.samsung.da.cumulativeDateUTC": "1786280400", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0167", + "UpdateAllow_NotAllowed", + "Course_87", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "WashingTimes_0", + "DrumCleanProposal_40", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_74", + "BubbleSoak_Off", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_421C20820437A2042C", + "SendToDevice_On", + "GMT_04", + "PreWashSetting_Off", + "IntensiveSetting_Off", + "CloudCourse_0021550449284D134AA04C0035F004F005F0AC00", + "CloudExtraCourse_0A5C286B2D0C55301A", + "OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00", + "BubbleSoakSet_00F0F0F00000F0F000000000F000", + "EnergyLevelSet_050304040501050404030201020401", + "MostUsed_1B841E923FA67F00000000000000", + "PreWashAvailableSet_F0F0F0F00000F0F000F0F000F000", + "IntensiveAvailableSet_F0F0F0F00000F0F000F0F000F000", + "TextureLevel_None", + "SavingModeCondition_010102151C1BA0961C8F251C0A061A207F5C55656B0C2D3034030149035E2830", + "SavingMode_Off", + "WelcomeLighting_1", + "SupportedWelcomeLighting_000102", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2025-08-18T14:56:34|2025-11-02T21:08:39|2026-01-19T13:00:50|2026-03-23T12:10:12|2026-06-27T11:07:22|2026-08-09T10:13:44", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F088000913FA67F7485209204A5208780009000A00006841E930FA30F7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F34841E923FA67FA0811E933FA33F" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:14:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:14:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B06C", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "WF1", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02986A260118(A182)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20348141240110090FFFFF203495412406195503FFFF(01672034814120349541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "03481A24011009,03495A24061955", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2025984624053003032FFFFFFFFFFFFFFFFFFFFFFFFE(016720259846FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02598F24053003,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "30", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "90" + ], + "x.com.samsung.da.spinLevel": "800", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "x.com.samsung.da.rinseCycles": "3", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_87", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/water/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeWater": "7512800" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR", + "en_US" + ] + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(WW8XCGC04AAEEG)" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "2026-03-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01672034814120349541", + "versions": [ + "24011009", + "24061955" + ], + "visVersion": "240619" + }, + { + "type": "Micom", + "modelId": "016720259846FFFFFFFF", + "versions": [ + "24053003", + "FFFFFFFF" + ], + "visVersion": "240530" + }, + { + "type": "Micom", + "modelId": "016720259846FFFFFFFF", + "versions": [ + "24053003", + "FFFFFFFF" + ], + "visVersion": "240530" + } + ] + } + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Volume_Off", + "Volume_Low", + "Volume_Med", + "Volume_High" + ], + "setBuzzerSound": "Volume_Low", + "supportedFinishSound": [ + "FinishSound_1", + "FinishSound_2", + "FinishSound_3" + ], + "setFinishSound": "FinishSound_2" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "DE" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "0000-00-00T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Berlin", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py new file mode 100644 index 0000000..39e201d --- /dev/null +++ b/tests/test_cloud_courses.py @@ -0,0 +1,312 @@ +"""Cloud "Download" programs on laundry devices (issue #342). + +The store and blob parsing live in cloudcourse.py; how they reach the cycle +select lives in registry/capabilities/laundry.py. Both are covered here, +against the two real dumps in the corpus that carry these tokens: + + washer_ww5000c_cloud the issue #342 reporter's WW5000C, sitting on + Download with a one-time Jeans override loaded over + a saved Sports default. Advertises nine programs. + washer_wa55a7700av a WA55A7700AV sitting on an ordinary local course, + with a saved program and the FFFF "none" sentinel in + the one-time slot. Advertises two programs. +""" + +import pytest + +from custom_components.localthings import cloudcourse +from custom_components.localthings.registry.capabilities import laundry, washer +from custom_components.localthings.registry.entities import SelectDesc +from tests.conftest import _load_device + +# The reporter's nine captured programs, keyed by the slot byte the device +# itself advertises. Only used to drive the tests -- nothing in the shipped +# code carries a table of these (see cloudcourse.py's module docstring). +SPORTS = "0021550449284D134AA04C0035F004F005F0AC00" +JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00" +TOWELS = "00040A0449404D134AB84C0035F004F005F0AC00" + + +def _cycle_desc(): + return next( + e for e in washer.WASHER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc) + ) + + +def _rep(options, cloud=None): + rep = {"x.com.samsung.da.options": list(options)} + if cloud is not None: + rep[cloudcourse.FIELD] = cloud + return rep + + +class TestBlobParsing: + def test_slot_is_byte_two(self): + """Confirmed against every program in both dumps: byte 2 of a blob + is the slot id its own CloudExtraCourse_ token advertises.""" + assert cloudcourse.slot_of(SPORTS) == "55" + assert cloudcourse.slot_of(JEANS) == "6B" + assert cloudcourse.slot_of(TOWELS) == "0A" + + def test_ffff_prefix_is_not_a_program(self): + """WA55A7700AV reports this while sitting on a local course -- it + means 'no one-time override', not a program, and its byte 2 is not + one of the slots that device advertises.""" + sentinel = "FFFF010049004D004A804C0037F0AC00" + assert cloudcourse.is_loaded(sentinel) is False + assert cloudcourse.slot_of(sentinel) is None + + @pytest.mark.parametrize( + "bad", + [None, "", "zz", "0021", "0021550449284D134AA04C0035F004F005F0AC0"], + ) + def test_malformed_blobs_yield_no_slot(self, bad): + assert cloudcourse.slot_of(bad) is None + + def test_advertised_slots_preserve_device_order(self): + rep = _rep(["CloudExtraCourse_0A5C286B2D0C55301A"]) + assert cloudcourse.advertised_slots(rep) == [ + "0A", + "5C", + "28", + "6B", + "2D", + "0C", + "55", + "30", + "1A", + ] + + def test_no_cloud_tokens_means_unsupported(self): + assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"])) is False + + +class TestRealDumps: + def test_reporter_dump_advertises_nine_and_learns_both_loaded_blobs(self): + """One poll teaches two programs: the saved default and the loaded + one-time override are different programs on this dump.""" + rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] + assert len(cloudcourse.advertised_slots(rep)) == 9 + + store = cloudcourse.CloudCourses() + assert store.observe(rep) is True + assert store.blob("55") == SPORTS + assert store.blob("6B") == JEANS + # Learned but unnamed -- nothing is offerable yet. + assert store.named() == {} + assert store.view() == {} + + def test_reporter_dump_proposes_its_download_course(self): + rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] + store = cloudcourse.CloudCourses() + store.observe(rep) + assert store.download_candidates() == ["87"] + # A candidate is never used until confirmed. + assert store.download_course() is None + + def test_wa55_learns_its_saved_program_but_not_the_sentinel(self): + rep = _load_device("washer_wa55a7700av")["/course/vs/0"] + assert cloudcourse.advertised_slots(rep) == ["59", "58"] + + store = cloudcourse.CloudCourses() + store.observe(rep) + assert store.blob("59") == "001C590549164D114A224C2037F0AC22" + assert store.blob("01") is None # the FFFF sentinel's byte 2 + + def test_wa55_proposes_no_download_course(self): + """It is sitting on an ordinary local course with no override + loaded, so there is nothing to infer -- exactly the case where + guessing a code would start the wrong cycle.""" + rep = _load_device("washer_wa55a7700av")["/course/vs/0"] + store = cloudcourse.CloudCourses() + store.observe(rep) + assert store.download_candidates() == [] + + def test_undiscovered_counts_against_what_the_device_advertises(self): + rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] + store = cloudcourse.CloudCourses() + store.observe(rep) + # Both learned slots are still unnamed, so all nine are outstanding. + assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 9 + store.set_name("55", "Sports") + assert "55" not in cloudcourse.undiscovered(rep, store.snapshot()) + assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 8 + + +class TestStoreRules: + def test_only_advertised_slots_are_recorded(self): + """A blob for a slot this appliance doesn't list isn't a program it + offers.""" + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"])) + assert store.blob("6B") is None + + def test_a_relearned_blob_replaces_the_old_payload(self): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])) + rewritten = SPORTS.replace("F005F0AC00", "F005F0AC11") + assert store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{rewritten}"])) is True + assert store.blob("55") == rewritten + + def test_observing_the_same_rep_twice_changes_nothing(self): + store = cloudcourse.CloudCourses() + rep = _rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]) + assert store.observe(rep) is True + assert store.observe(rep) is False + + def test_view_is_empty_until_a_download_course_is_confirmed(self): + """Both halves are required: without the course code there is no + write to build, so nothing should reach the select.""" + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])) + store.set_name("55", "Sports") + assert store.view() == {} + store.set_download_course("87") + assert store.view()["programs"] == {"55": {"blob": SPORTS, "name": "Sports"}} + + def test_round_trips_through_the_entry(self): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])) + store.set_name("55", "Sports") + store.set_download_course("87") + restored = cloudcourse.CloudCourses(store.snapshot()) + assert restored.snapshot() == store.snapshot() + assert restored.view() == store.view() + + @pytest.mark.parametrize( + "junk", + [ + "not a dict", + {"slots": "nope"}, + {"slots": {"55": {"blob": "garbage", "name": "Sports"}}}, + # blob's own byte 2 disagrees with the key it is filed under + {"slots": {"99": {"blob": SPORTS, "name": "Sports"}}}, + ], + ) + def test_a_hand_edited_entry_cannot_crash_setup(self, junk): + assert cloudcourse.CloudCourses(junk).snapshot()["slots"] == {} + + +class TestCycleSelectIntegration: + """The cloud programs ride in the ordinary cycle select -- on the + appliance's dial, Download is one course among the rest.""" + + def _cloud(self): + return { + "download_course": "87", + "programs": {"55": {"blob": SPORTS, "name": "Sports"}}, + } + + def test_local_courses_are_unchanged_without_cloud_data(self): + desc = _cycle_desc() + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}} + assert desc.options(live) == ["1C", "1D"] + + def test_named_programs_join_the_option_list_after_local_courses(self): + desc = _cycle_desc() + live = { + "/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C87"}, + "/course/vs/0": _rep(["Course_87"], self._cloud()), + } + assert desc.options(live) == ["1C", "87", "cloud:55"] + + def test_an_unnamed_program_is_never_offered(self): + desc = _cycle_desc() + live = { + "/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C"}, + "/course/vs/0": _rep( + ["Course_87"], + {"download_course": "87", "programs": {}}, + ), + } + assert desc.options(live) == ["1C"] + + def test_label_is_the_users_own_name(self): + desc = _cycle_desc() + resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())} + assert desc.display_fn("cloud:55", resources) == "Sports" + + def test_unknown_cloud_value_gets_no_invented_label(self): + desc = _cycle_desc() + resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())} + assert desc.display_fn("cloud:99", resources) is None + + def test_state_reports_the_loaded_program_while_on_download(self): + desc = _cycle_desc() + rep = _rep(["Course_87", f"OneTimeCloudCourse_{SPORTS}"], self._cloud()) + assert desc.rep_fn(rep) == "cloud:55" + + def test_state_falls_back_to_the_saved_program(self): + """No one-time override loaded: the appliance runs the saved default, + which the reporter confirmed by leaving Download and returning.""" + desc = _cycle_desc() + rep = _rep( + ["Course_87", f"CloudCourse_{SPORTS}", "OneTimeCloudCourse_FFFF010049004D004A804C00"], + self._cloud(), + ) + assert desc.rep_fn(rep) == "cloud:55" + + def test_a_stale_program_token_is_not_reported_on_a_local_course(self): + """Tokens are replaced by prefix and never evicted, so a one-time + program outlives its run. Reporting 'Sports' while a cotton cycle + runs would be a live lie about what the machine is doing.""" + desc = _cycle_desc() + rep = _rep(["Course_1C", f"OneTimeCloudCourse_{SPORTS}"], self._cloud()) + assert desc.rep_fn(rep) == "1C" + + def test_selecting_a_program_switches_course_and_loads_it_in_one_write(self): + """Confirmed on hardware: writing the program token alone while + another course is selected is silently ignored.""" + desc = _cycle_desc() + rep = _rep(["Course_1C"], self._cloud()) + path, body = desc.write_fn("cloud:55", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Course_87", f"OneTimeCloudCourse_{SPORTS}"]} + + def test_selecting_a_local_course_still_writes_one_token(self): + desc = _cycle_desc() + rep = _rep(["Course_87"], self._cloud()) + _, body = desc.write_fn("1C", rep) + assert body == {"x.com.samsung.da.options": ["Course_1C"]} + + def test_no_write_without_a_confirmed_download_course(self): + desc = _cycle_desc() + rep = _rep(["Course_1C"], {"programs": {"55": {"blob": SPORTS, "name": "Sports"}}}) + assert desc.write_fn("cloud:55", rep) is None + + def test_no_write_for_an_unknown_program(self): + desc = _cycle_desc() + rep = _rep(["Course_1C"], self._cloud()) + assert desc.write_fn("cloud:99", rep) is None + + +class TestOptionsMergePreservesSiblingTokens: + def test_two_token_write_merges_like_the_device_does(self): + """The write carries only the changed tokens; merge_options_field is + what keeps the optimistic cache entry complete during the settle + window. Both tokens must land, and unrelated ones must survive.""" + from custom_components.localthings.registry.capabilities.common import merge_options_field + + cached = [ + "DeviceType_0167", + "Course_1C", + f"CloudCourse_{SPORTS}", + "CloudExtraCourse_0A5C286B2D0C55301A", + f"OneTimeCloudCourse_{TOWELS}", + "GMT_04", + ] + merged = merge_options_field(cached, ["Course_87", f"OneTimeCloudCourse_{SPORTS}"]) + assert "Course_87" in merged + assert f"OneTimeCloudCourse_{SPORTS}" in merged + # The saved default and the device's own slot list are untouched. + assert f"CloudCourse_{SPORTS}" in merged + assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged + assert "DeviceType_0167" in merged + + def test_cloud_prefixes_do_not_poison_the_course_lookup(self): + """'Course_' is a prefix of neither 'CloudCourse_' nor + 'OneTimeCloudCourse_' only because option_value anchors at position + 0 -- one character away from being wrong, so it is pinned.""" + options = [f"CloudCourse_{SPORTS}", f"OneTimeCloudCourse_{JEANS}", "Course_1C"] + assert laundry.option_value(options, "Course") == "1C" + assert cloudcourse.option_value(options, "Course") == "1C" diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py new file mode 100644 index 0000000..ab0b738 --- /dev/null +++ b/tests/test_cloud_courses_flow.py @@ -0,0 +1,322 @@ +"""End-to-end wiring for cloud "Download" cycles (issue #342). + +The store's own rules are covered in test_cloud_courses.py. This file covers +the parts that only exist once a coordinator is running: learning from an +applied rep, persisting to the config entry, surfacing the store to entity +descriptors through the synthetic field, the Repairs nudge, and the options +flow that supplies the names. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, cast + +import cbor2 +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.localthings import cloudcourse +from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN +from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.registry.entities import SelectDesc +from tests.conftest import _load_device +from tests.test_subdevice_discovery import ENTRY_DATA + +FIXTURE = "washer_ww5000c_cloud" +COURSE = cloudcourse.COURSE_HREF + +SPORTS = "0021550449284D134AA04C0035F004F005F0AC00" +JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00" + + +async def _flush(hass: HomeAssistant) -> None: + """hass.add_job hops used by the persist path.""" + for _ in range(3): + await asyncio.sleep(0) + await hass.async_block_till_done() + + +def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={**ENTRY_DATA, **(data or {})}, + unique_id="localthings_CLOUD-COURSE-TEST", + ) + entry.add_to_hass(hass) + return entry + + +async def _coordinator(hass: HomeAssistant, entry=None) -> LocalThingsCoordinator: + coordinator = LocalThingsCoordinator(hass, entry or _entry(hass)) + resources = _load_device(FIXTURE) + coordinator._run_discovery(resources) + for href, rep in resources.items(): + coordinator._observe.apply(href, rep, source="poll") + await _flush(hass) + return coordinator + + +def _cycle_state(coordinator: LocalThingsCoordinator): + from custom_components.localthings.registry.adapter import flatten + + return flatten(coordinator.bound, coordinator.entity_resources()).get("cycle") + + +def _cycle_desc(coordinator: LocalThingsCoordinator) -> SelectDesc: + return cast(SelectDesc, next(b.desc for b in coordinator.bound if b.desc.key == "cycle")) + + +def _cycle_options(coordinator: LocalThingsCoordinator): + from custom_components.localthings.registry.subdevices import MAIN + + return _cycle_desc(coordinator).options(coordinator.canonical_resources(MAIN)) + + +async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistant): + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + + assert coordinator.cloud_courses.blob("55") == SPORTS + assert coordinator.cloud_courses.blob("6B") == JEANS + # Survives a restart -- the payload is only visible while loaded. + assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS + + +async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant): + """An optimistic rep is what this integration just wrote, not what the + appliance reported.""" + coordinator = LocalThingsCoordinator(hass, _entry(hass)) + coordinator._observe.apply( + COURSE, + {"x.com.samsung.da.options": ["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]}, + source="optimistic", + ) + await _flush(hass) + assert coordinator.cloud_courses.blob("55") is None + + +async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant): + coordinator = await _coordinator(hass) + options = _cycle_options(coordinator) + assert all(not o.startswith(cloudcourse.RAW_PREFIX) for o in options) + # The local courses are untouched. + assert "87" in options + + +async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant): + coordinator = await _coordinator(hass) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Sports") + await _flush(hass) + + assert "cloud:55" in _cycle_options(coordinator) + # The fixture is sitting on Download with a Jeans one-time override, and + # Jeans is still unnamed -- so the state falls through to the raw course. + assert _cycle_state(coordinator) == "87" + + coordinator.set_cloud_course_name("6B", "Jeans") + await _flush(hass) + assert _cycle_state(coordinator) == "cloud:6B" + + +async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeAssistant): + """It is merged at read time only: last_resources stays exactly what the + appliance reported, so it can't be polled over, written back, or land in + a diagnostics dump.""" + coordinator = await _coordinator(hass) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Sports") + await _flush(hass) + + assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE] + assert cloudcourse.FIELD not in coordinator.last_resources[COURSE] + assert cloudcourse.FIELD not in coordinator.resource(COURSE) + + +async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant): + """Driven through async_send_command rather than write_fn directly: the + command path builds its own rep, and a rep taken straight off the state + cache carries no cloud programs, so the write would silently no-op.""" + coordinator = await _coordinator(hass) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Sports") + await _flush(hass) + + sent: list[tuple[list[str], bytes]] = [] + + class _FakeSession: + def post(self, path_segs, payload, timeout=None): + sent.append((path_segs, payload)) + return 0x44, b"" + + coordinator._session = cast(Any, _FakeSession()) + bound = next(b for b in coordinator.bound if b.desc.key == "cycle") + await coordinator.async_send_command(bound, "cloud:55") + + assert len(sent) == 1 + path_segs, payload = sent[0] + assert path_segs == ["course", "vs", "0"] + assert cbor2.loads(payload)["x.com.samsung.da.options"] == [ + "Course_87", + f"OneTimeCloudCourse_{SPORTS}", + ] + # The optimistic merge keeps the rest of the array intact for the settle + # window -- including the device's own slot list and the saved default. + merged = coordinator.resource(COURSE)["x.com.samsung.da.options"] + assert "Course_87" in merged + assert f"OneTimeCloudCourse_{SPORTS}" in merged + assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged + + +async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssistant): + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + coordinator._refresh_cloud_course_issue() + await _flush(hass) + + registry = ir.async_get(hass) + issue_id = f"cloud_courses_{entry.entry_id}" + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert (issue.translation_placeholders or {})["total"] == "9" + + coordinator.set_cloud_download_course("87") + for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()): + # Only two are learned; name every advertised slot to close the gap. + coordinator.cloud_courses.observe( + { + "x.com.samsung.da.options": [ + "CloudExtraCourse_0A5C286B2D0C55301A", + f"CloudCourse_0000{slot}0449284D134AA04C0035F004F005F0AC00", + ] + } + ) + coordinator.set_cloud_course_name(slot, f"Program {slot}") + await _flush(hass) + + assert registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_no_repair_for_a_device_without_downloaded_programs(hass: HomeAssistant): + entry = _entry(hass) + coordinator = LocalThingsCoordinator(hass, entry) + resources = _load_device("washer") + coordinator._run_discovery(resources) + for href, rep in resources.items(): + coordinator._observe.apply(href, rep, source="poll") + coordinator._refresh_cloud_course_issue() + await _flush(hass) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None + + +async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant): + entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}}) + coordinator = await _coordinator(hass, entry) + # Dropped on restore, then relearned from the live poll. + assert coordinator.cloud_courses.blob("55") == SPORTS + + +# --------------------------------------------------------------------------- +# Options flow +# --------------------------------------------------------------------------- + + +async def _options_handler(hass: HomeAssistant, coordinator: LocalThingsCoordinator): + from custom_components.localthings.config_flow import LocalThingsOptionsFlow + + entry = coordinator.config_entry + assert entry is not None + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + handler = LocalThingsOptionsFlow() + handler.hass = hass + # OptionsFlow.config_entry resolves through `handler`, which the flow + # manager normally sets when it starts the flow. + handler.handler = entry.entry_id + return handler + + +async def test_the_menu_offers_download_cycles_only_when_the_device_has_them( + hass: HomeAssistant, +): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_init() + assert "cloud_courses" in result["menu_options"] + + +async def test_naming_through_the_flow_persists(hass: HomeAssistant): + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + handler = await _options_handler(hass, coordinator) + + await handler.async_step_cloud_courses() + await handler.async_step_cloud_courses( + {"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"} + ) + await _flush(hass) + + assert coordinator.cloud_courses.named() == {"55": "Sports", "6B": "Jeans"} + assert entry.data[CONF_CLOUD_COURSES]["download_course"] == "87" + + +async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + result = await handler.async_step_cloud_courses( + {"name_55": "Sports", "name_6B": "sports", "download_course": "87"} + ) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} + assert coordinator.cloud_courses.named() == {} + + +async def test_the_flow_rejects_a_name_that_shadows_a_local_course(hass: HomeAssistant): + """'Drum Clean' is course 74 on this appliance's own list. Two options + rendering the same label would resolve to whichever comes first.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + result = await handler.async_step_cloud_courses( + {"name_55": "Drum Clean", "download_course": "87"} + ) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} + assert coordinator.cloud_courses.named() == {} + + +async def test_clearing_a_name_removes_the_program_from_the_select(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "87"}) + await _flush(hass) + assert "cloud:55" in _cycle_options(coordinator) + + await handler.async_step_cloud_courses({"name_55": "", "download_course": "87"}) + await _flush(hass) + assert "cloud:55" not in _cycle_options(coordinator) + + +async def test_without_a_confirmed_download_course_nothing_is_offered(hass: HomeAssistant): + """Names alone aren't enough -- there'd be no course code to write.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": ""}) + await _flush(hass) + + assert coordinator.cloud_courses.named() == {"55": "Sports"} + assert "cloud:55" not in _cycle_options(coordinator) + + +async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_cloud_courses() + + assert result["step_id"] == "cloud_courses" + assert result["description_placeholders"]["total"] == "9" + # Two learned so far, seven still to walk through on the appliance. + assert result["description_placeholders"]["found"] == "2" + assert coordinator.cloud_courses.download_candidates() == ["87"] diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py index 1e9d232..148a8ed 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -5,7 +5,7 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we check the dishwasher wiring and its device-specific options. """ -from custom_components.localthings.registry.capabilities import dishwasher, laundry +from custom_components.localthings.registry.capabilities import dishwasher from custom_components.localthings.registry.entities import SwitchDesc @@ -15,7 +15,8 @@ class TestCycleOptions: def test_cycle_desc_uses_shared_cycle_options(self): desc = self._cycle() - assert desc.options is laundry.cycle_options + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E82"}} + assert desc.options(live) == ["0E", "82"] assert desc.translation_key == "dishwasher_cycle" def test_exists_only_when_edit_course_list_is_live(self): diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 1791efb..a6d3ee4 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -2,7 +2,7 @@ from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_model -from custom_components.localthings.registry.capabilities import dryer, ignored, laundry +from custom_components.localthings.registry.capabilities import dryer, ignored from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import SelectDesc from tests.conftest import _load_device @@ -82,7 +82,8 @@ def test_course_bound_to_shared_course_vs_0(): table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}} assert desc.translation_key(table_03) == "dryer_cycle_table_03" assert desc.translation_key({}) == "cycle" - assert desc.options is laundry.cycle_options + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1620"}} + assert desc.options(live) == ["16", "20"] rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]} assert desc.rep_fn is not None assert desc.rep_fn(rep) == "16" diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a0ce829..bf84d29 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1496,3 +1496,26 @@ def test_resources_from_batch_preferred_over_flat(): } result = _resources_from_dump(dump) assert result == {"/foo": {"x": 1}} + + +def test_registry_reproduces_golden_state_keys_for_washer_ww5000c_cloud(): + """The issue #342 reporter's WW5000C (DA_WM_TP1_21_COMMON, Table_02), + captured while sitting on its Download course with a one-time Jeans + program loaded over a saved Sports default. + + The cloud "Download" programs it advertises add no entity of their own + -- they ride in the existing cycle select -- so this golden is the guard + that discovering them never grows the entity set. + + Its /wm/editcourse/vs/0 is empty, so its course list comes from the + supportedOptions fallback (issue #1).""" + from tests.conftest import _load_device + + resources = _load_device("washer_ww5000c_cloud") + golden = json.loads((GOLDEN / "washer_ww5000c_cloud.json").read_text()) + state_keys = _new_state_keys("washer_ww5000c_cloud", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 2934d6c..56612c9 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -233,7 +233,10 @@ class TestCycleSelect: assert desc.key == "cycle" assert desc.translation_key == "dryer_cycle" assert desc.icon == "mdi:tumble-dryer" - assert desc.options is laundry.cycle_options + # Behavior, not identity: the option list is the device's own live + # course list (plus any named cloud programs -- see cycle_select). + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_161C"}} + assert desc.options(live) == ["16", "1C"] def test_reads_raw_course_code_from_options(self): desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index bb68900..9eb92e9 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -7,7 +7,7 @@ fallback pairs and the energy meter in test_common_capabilities.py. from datetime import UTC -from custom_components.localthings.registry.capabilities import laundry, washer +from custom_components.localthings.registry.capabilities import washer from custom_components.localthings.registry.entities import SelectDesc @@ -157,8 +157,20 @@ class TestWasherCourse: for e in washer.WASHER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc) ) - assert desc.options is laundry.cycle_options - assert desc.display_fn is laundry.washer_cycle_fallback + # Behavior, not identity: cycle_select wraps both hooks to fold in + # cloud "Download" programs (issue #342), so the plain functions are + # no longer handed through as-is. + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}} + assert desc.options(live) == ["1C", "1D"] + # display_fn still resolves a personal course name through + # washer_cycle_fallback for a non-cloud value. + assert desc.display_fn is not None + personal = { + "/wm/personalcourse/vs/0": { + "x.com.samsung.da.courses": ["A1_01044D79436F"], + } + } + assert desc.display_fn("A1", personal) == "MyCo" def test_exists_only_when_edit_course_list_is_live(self): """No hardcoded course table is kept -- the selector only appears