From f45bd6a72c7d80060dbaf2e785133a9801c499dc Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 9 Aug 2026 21:21:10 +0000 Subject: [PATCH 01/14] 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 68b6208f..9020e571 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 00000000..3780b186 --- /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 97c76fb6..8f8b5191 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -34,6 +34,8 @@ 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 .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 @@ def _coordinator(self): 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 @@ async def async_step_forget_learned_modes( 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 3e62fcc9..3603e0b8 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -48,6 +48,15 @@ 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 0e8de00d..2977fbe8 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -22,8 +22,12 @@ 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 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # 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 @@ def resource(self, href: str) -> dict: 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 @@ def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]: 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 @@ def _on_rep_applied(self, href: str, rep: dict, source: str) -> None: 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 @@ def _on_rep_applied(self, href: str, rep: dict, source: str) -> None: 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 @@ def _push_cache_snapshot(self) -> None: 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 @@ async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> N 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 42058029..c66618f9 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -23,6 +23,7 @@ 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)) + + +# --------------------------------------------------------------------------- +# 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 cycle_write(p, rep, href=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 key(resources): 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 95c2e5f1..b6e5db27 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 254f3316..f76fcb81 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 43ce1f43..16da3909 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 6d2576fa..226b25ca 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 b9528b15..9735821a 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 043b1d30..675c2bed 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 542826fa..e5b4eb64 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 00000000..f4c3e1b5 --- /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 00000000..54fa4cdb --- /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 00000000..02870e20 --- /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 00000000..39e201d4 --- /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 00000000..ab0b7382 --- /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 1e9d2324..148a8eda 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -5,7 +5,7 @@ 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 @@ def _cycle(self): 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 1791efb9..a6d3ee46 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 a0ce8292..bf84d294 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 2934d6cc..56612c9e 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -233,7 +233,10 @@ def test_builds_labelled_cycle_select(self): 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 bb689005..9eb92e9f 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -7,7 +7,7 @@ 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 @@ def test_cycle_desc_uses_cycle_options_callable(self): 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 From 22b4508f95912867b77314fd9a6da50f3edeefce Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 9 Aug 2026 21:32:26 +0000 Subject: [PATCH 02/14] docs: the two cloud-blob widths are the same grammar, not two formats Byte-aligning the WA55A7700AV's 16-byte payload against the WW5000C's 20-byte one: identical header, and the first four tag/value pairs are the same tags in the same order at the same offsets -- the part that carries per-program data has one shape on both boards. The whole width difference is two trailing pairs the WA55 doesn't carry, and on the WW5000C that trailing section is byte-identical across all nine programs, so it isn't program data at all. Doesn't change the conclusion -- the four shared tags carry non-overlapping value ranges between the boards, so the encoding is still board-specific and blobs are still replayed whole. Also records why the WA55's /washer/vs/0 readings can't be used to confirm a decode: that unit is on a local course, not its cloud course. --- docs/investigations/download-cycle.md | 51 +++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index f4c3e1b5..94fc7154 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -117,12 +117,51 @@ It does not survive the second device. Against the WA55A7700AV's - 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. +### What *does* survive is the grammar + +The two boards' payloads are different lengths (20 vs 16 bytes) but not a +different format — same header, same leading fields, two fewer optional +trailing ones: + +``` +WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00 +WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22 +``` + +- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical + layout on both. +- Then a tag/value stream whose **first four tags are the same, in the same + order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the + four whose values vary per program. +- Then a fixed tail, terminated on both by an `AC:` pair. The entire + width difference is two trailing pairs the WA55 doesn't carry. + +The tail is not program data. Across all nine WW5000C programs, byte 3 is +always `04` and bytes 12–19 are byte-identical — every trailing pair carries +value `F0` except the `AC` terminator, and `35` vs `37` looks like a board or +profile marker rather than a field. (Byte 3 is not a field count: the board +with the *higher* value has *fewer* pairs.) + +So the payload is tag/value, not fixed offsets — but knowing the grammar +doesn't recover the values. The same four tags carry non-overlapping ranges +between the two boards: + +| tag | WW5000C (9 programs) | WA55 | +| --- | --- | --- | +| `49` | `00, 28, 30, 40, 58` | `16` | +| `4D` | `00, 12, 13, 14` | `11` | +| `4A` | `A0, B0, B8` | `22` | +| `4C` | `00` (all nine) | `20` | + +Same field, board-specific encoding. Decoding it properly needs a third +device; one device's fit is a coincidence-shaped hypothesis, not a format. + +**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads +Warm / High / 1, which looks like it could confirm a decode of that unit's +`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal), +not on its cloud course, so those values describe the local cycle it has +selected, not the saved cloud program. A cross-check like this is only +evidence when the machine is actually loaded with the program being decoded. 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 From 9d28b088cbfe7f0b9da2e44589075a40940179aa Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 9 Aug 2026 21:46:29 +0000 Subject: [PATCH 03/14] laundry: cover a device that advertises cloud cycles it has never loaded A survey of every laundry diagnostics dump attached to an issue turned up 14 devices, 4 of which carry cloud-course tokens. Two were already known; the two new ones are both useful, and one contradicts something the investigation write-up asserted. A DW5000C dishwasher (issues #113/#123) advertises four downloaded programs and carries no payload token for any of them. That is a shape the corpus didn't have: the feature is not washer-only (DA_DW, not DA_WM), and a device can name programs whose payloads have never been observed. The existing code already handles it correctly -- nothing learnable, nothing offered, gap still counted for the Repairs issue -- so this adds the fixture, golden, and tests that keep it that way. A second WW5000C (issues #259/#343, firmware _B048) holds the same saved program as the first one's captured "Towels", and the two payloads differ at exactly one byte: byte 3, 04 against 06. Everything else -- id, slot, all four varying tag values, the whole tail -- is identical. So byte 3 is neither a per-board constant nor a property of the program, and the doc's claim that it is always 04 on this board was wrong. That is also the strongest argument yet for learning payloads per device: a catalog keyed on program id would have shipped one unit's byte 3 to the other. Nothing changes in the implementation as a result -- it never had a catalog -- but the reasoning is now backed by evidence rather than caution. Also recorded: both WW5000C units advertise the byte-identical slot list despite different firmware, so the program set looks factory- or region-assigned rather than user-curated; and a sentinel's byte 2 equals the selected course on one dump but not the other, so it stays unused. --- docs/investigations/download-cycle.md | 94 +++++- .../dishwasher_dw5000c_cloud_device.json | 297 ++++++++++++++++++ .../golden/dishwasher_dw5000c_cloud.json | 26 ++ tests/test_cloud_courses.py | 53 ++++ tests/test_golden_regression.py | 20 ++ 5 files changed, 474 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/dishwasher_dw5000c_cloud_device.json create mode 100644 tests/fixtures/golden/dishwasher_dw5000c_cloud.json diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index 94fc7154..99d0f814 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -7,15 +7,29 @@ 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. +Four devices in the corpus carry these tokens (a survey of every laundry +diagnostics dump attached to an issue turned up 14 devices; the other 10 have +no cloud tokens at all, so this is a minority feature): + +| dump | model | slots advertised | payloads seen | blob width | +| --- | --- | --- | --- | --- | +| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes | +| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes | +| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 | **0** | — | +| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes | + +Three things follow immediately from that table: + +- **It isn't washer-only.** The DW5000C is a `DA_DW_` dishwasher. +- **A device can advertise programs it has never loaded.** The DW5000C names + four slots and carries no `CloudCourse_`/`OneTimeCloudCourse_` token at + all. Nothing about it is learnable until its owner runs one, which is + exactly the situation the Repairs issue exists to explain. +- **Both WW5000C units advertise the byte-identical slot list** + (`0A5C286B2D0C55301A`, same nine slots in the same order) despite different + firmware builds. Either the set is a factory/regional default rather than + something each owner curates, or the two dumps share an owner — unresolved, + but worth knowing before assuming a user picked their own programs. ## The three tokens @@ -136,11 +150,44 @@ WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC: - Then a fixed tail, terminated on both by an `AC:` pair. The entire width difference is two trailing pairs the WA55 doesn't carry. -The tail is not program data. Across all nine WW5000C programs, byte 3 is -always `04` and bytes 12–19 are byte-identical — every trailing pair carries -value `F0` except the `AC` terminator, and `35` vs `37` looks like a board or -profile marker rather than a field. (Byte 3 is not a field count: the board -with the *higher* value has *fewer* pairs.) +The tail is not program data. Across all nine WW5000C programs bytes 12–19 +are byte-identical — every trailing pair carries value `F0` except the `AC` +terminator, and `35` vs `37` looks like a board or profile marker rather than +a field. (It is not a field count either: the board with the *higher* leading +byte has *fewer* pairs.) + +### Byte 3 is not part of a program's identity + +Worth its own heading, because it is the single strongest argument against +ever shipping a table of payloads. The second WW5000C (issues #259/#343, +firmware `_B048`) has its saved `CloudCourse` set to the same program as the +first one's "Towels" capture — and the two payloads differ at exactly one +byte: + +``` +_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00 +_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00 + ^^ +``` + +Same program id, same slot, same values on all four varying tags, same tail. +Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both +are WW5000C) nor a property of the program (identical in every other +respect) — most likely a download revision or sequence counter. + +A hardcoded catalog keyed on program id would therefore have shipped one +unit's byte 3 to the other unit. Whether the appliance would reject that, or +accept it and do something unintended, is untested and does not need to be: +every payload is learned from the device it will be replayed to. + +### Sentinels + +The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on +the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its +byte 2 is *not* reliably meaningful: it equals the currently selected course +on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on +`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF` +prefix, and its byte 2 is not an advertised slot in either dump anyway. So the payload is tag/value, not fixed offsets — but knowing the grammar doesn't recover the values. The same four tags carry non-overlapping ranges @@ -194,5 +241,20 @@ sensors this decode would have enabled were dropped for the same reason. 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. +3. What does byte 3 count? It moves between two units holding the identical + program (`04` vs `06`) and is `00` on every sentinel. A revision or + download counter is the obvious guess; a dump taken before and after + re-downloading the same program would confirm it. +4. **The value encoding is still open, and a third *washer* won't + necessarily settle it.** The survey found one, but its saved program is a + duplicate of one already captured, so it adds no new tag values. What is + actually needed is a dump from a board whose `/washer/vs/0` speaks in + named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a + downloaded program* — then the payload's `49`/`4A` values can be read + against settings that describe the same program. The WA55 is such a + board but was captured on a local course, which is why it can't be used + (see the trap above). +5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as + dishwasher course codes (its selected course is `86`), unlike the + washers' slots. One payload from that machine would show whether slot ids + are drawn from the course-code space on some boards. diff --git a/tests/fixtures/dishwasher_dw5000c_cloud_device.json b/tests/fixtures/dishwasher_dw5000c_cloud_device.json new file mode 100644 index 00000000..d74b1250 --- /dev/null +++ b/tests/fixtures/dishwasher_dw5000c_cloud_device.json @@ -0,0 +1,297 @@ +{ + "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": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "DishA_Disable", + "x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "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_0812", + "UpdateAllow_NotAllowed", + "CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D", + "CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C", + "CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C", + "Course_86", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "ProgressTimeSet_420622820456B20384", + "SendToDevice_Off", + "GMT_F2", + "SavingModeCondition_01010207828485868E8D020300", + "SavingMode_Off", + "DownloadCourseList_8F", + "StormWashZone_Off", + "AutoDoorRelease_On", + "Sound_On", + "WaterLevelSet_050404050302010205010400", + "CloudExtraCourse_8E8D8F02", + "EnergyLevelSet_050403050202010305040300", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "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": "Run", + "x.com.samsung.da.remainingTime": "00:44:00", + "x.com.samsung.da.progressPercentage": "28", + "x.com.samsung.da.delayStartTime": "00:00:00", + "x.com.samsung.da.progress": "Wash", + "x.com.samsung.da.supportedProgress": [ + "None", + "Predrain", + "Wash", + "Rinse", + "Drying", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Predrain", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "Wash", + "remainingTime": "00:44:00", + "progressPercentage": "28" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000", + "x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "WD0", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "00081A230213(A214)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "23060214,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/dishwasher/vs/0", + "rep": { + "x.com.samsung.da.highTemperatureDry": "Off", + "x.com.samsung.da.sanitize": "Off", + "x.com.samsung.da.selectedZone": "ON_ON", + "x.com.samsung.da.rinseLevel": "0", + "x.com.samsung.da.supportedSelectedZone": [ + "OFF_ON", + "ON_ON" + ], + "x.com.samsung.da.supportedSanitize": [ + "Off", + "On" + ], + "x.com.samsung.da.supportedHighTemperatureDry": [ + "Off", + "On" + ], + "x.com.samsung.da.supportedRinseLevel": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": true + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "CA" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": {} + }, + { + "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": "/water/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeWater": "2662000" + } + }, + { + "href": "/wm/submode/vs/0", + "rep": { + "setTemperatureUnit": "F" + } + } + ] +} diff --git a/tests/fixtures/golden/dishwasher_dw5000c_cloud.json b/tests/fixtures/golden/dishwasher_dw5000c_cloud.json new file mode 100644 index 00000000..74ccb567 --- /dev/null +++ b/tests/fixtures/golden/dishwasher_dw5000c_cloud.json @@ -0,0 +1,26 @@ +{ + "state_keys": [ + "alarm_code", + "auto_release_dry", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_saved_kwh", + "filter_status", + "filter_usage", + "finish_time", + "firmware_update", + "heated_dry", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "sanitize", + "storm_wash", + "water_liters" + ] +} diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index 39e201d4..29956f18 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -122,6 +122,59 @@ def test_wa55_proposes_no_download_course(self): store.observe(rep) assert store.download_candidates() == [] + def test_a_dishwasher_advertises_programs_it_has_never_loaded(self): + """DW5000C (issues #113/#123): CloudExtraCourse_ names four slots + with no CloudCourse_/OneTimeCloudCourse_ token anywhere in the array. + + Two things at once -- the feature is not washer-only (DA_DW, not + DA_WM), and a device can advertise programs whose payloads have never + been observed. Nothing is learnable here, so nothing is offerable, + but the gap is still countable and still worth telling the user + about.""" + rep = _load_device("dishwasher_dw5000c_cloud")["/course/vs/0"] + assert cloudcourse.advertised_slots(rep) == ["8E", "8D", "8F", "02"] + assert cloudcourse.supports_cloud_courses(rep) is True + + store = cloudcourse.CloudCourses() + assert store.observe(rep) is False + assert store.view() == {} + assert cloudcourse.undiscovered(rep, store.snapshot()) == ["8E", "8D", "8F", "02"] + + def test_byte_three_is_not_part_of_a_programs_identity(self): + """Two WW5000C units on different firmware (issue #342's _B06C and + issues #259/#343's _B048) report the same program -- same id, same + slot, same field values, same tail -- differing only at byte 3. + + This is the evidence against a shipped catalog of payloads: keyed on + program id, one unit's capture would have carried the other unit's + byte 3. Both are recorded and replayed per device instead, so + whatever byte 3 tracks never has to be understood.""" + b06c = "00040A0449404D134AB84C0035F004F005F0AC00" + b048 = "00040A0649404D134AB84C0035F004F005F0AC00" + assert b06c != b048 + # Same program by every identifier the device gives us. + assert cloudcourse.slot_of(b06c) == cloudcourse.slot_of(b048) == "0A" + assert b06c[:6] == b048[:6] + assert b06c[8:] == b048[8:] + # Learned separately per device, and each replays what it saw. + for blob in (b06c, b048): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"])) + assert store.blob("0A") == blob + + def test_a_sentinel_slot_byte_is_not_the_current_course(self): + """The two sentinels in the corpus disagree about this -- WA55's byte + 2 happens to equal its selected course, the WW5000C _B048's does not + (1B against Course_1C). Nothing keys off it: a sentinel is rejected + on its FFFF prefix, and its byte 2 is not an advertised slot either + way.""" + wa55 = "FFFF010049004D004A804C0037F0AC00" + b048 = "FFFF1B0049004D004A804C0035F004F005F0AC00" + assert cloudcourse.slot_of(wa55) is None + assert cloudcourse.slot_of(b048) is None + # Different board widths, same sentinel rule. + assert len(wa55) != len(b048) + def test_undiscovered_counts_against_what_the_device_advertises(self): rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] store = cloudcourse.CloudCourses() diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index bf84d294..3af27234 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1519,3 +1519,23 @@ def test_registry_reproduces_golden_state_keys_for_washer_ww5000c_cloud(): f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" ) + + +def test_registry_reproduces_golden_state_keys_for_dishwasher_dw5000c_cloud(): + """A DW5000C dishwasher (issues #113/#123) that advertises four + downloaded programs it has never loaded -- CloudExtraCourse_ with no + CloudCourse_/OneTimeCloudCourse_ payload alongside it (issue #342). + + Proves the cloud-cycle machinery is not washer-only and, more usefully, + that a device can advertise programs whose payloads have never been + seen. It adds no entity of its own either way.""" + from tests.conftest import _load_device + + resources = _load_device("dishwasher_dw5000c_cloud") + golden = json.loads((GOLDEN / "dishwasher_dw5000c_cloud.json").read_text()) + state_keys = _new_state_keys("dishwasher_dw5000c_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))}" + ) From b921bdbb280176a34200e720dfb413b67c7508f7 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:16:07 +0000 Subject: [PATCH 04/14] laundry: fix six issues from review of the cloud-cycle branch Also drops appliance-specific wording from the new user-facing strings. The setup step said "your washer" and told people to "turn the dial", which is wrong for the DW5000C dishwasher that advertises the same tokens. The two that could have caused a wrong wash cycle: - The Download-course candidate was counted on every poll that saw a loaded one-time payload, not on the polls where one was actually loaded. Since a stale token is never evicted, it keeps being reported through however long the appliance then sits on some ordinary course -- so "most frequent" ranked by dwell time. Reproduced: one poll on Course_87 then 200 on Course_1B suggests 1B, and accepting the suggestion makes picking a download program start a Cotton wash. Now only a change of the payload counts, which is the moment the device is known to accept a program. - The Download-course dropdown had custom_value=True, contradicting its own comment, so a typed-in code went into the Course_ token of a real write unchecked. Off now, plus a server-side check against the device's own course list where the value is stored. Two that quietly broke things beyond this feature: - cycle_select now always supplies a display_fn (to label cloud programs), which defeated select._display's "no state table and no fallback -> return raw" exit. Every dryer, dishwasher and air dresser on an unrecognized course table would have had its options and state reshaped from '0E' to '0 E', breaking automations and recorder history. The exit now keys off whether anything actually named the value, not whether a fallback existed. - The synthetic cloud field reached diagnostics, which reads canonical_resources -- publishing user-typed program names in a dump people paste into issues, directly against the comment claiming it never could. Dropped at the redaction boundary, with a matching strip for the debug read service, which wants device state unredacted but shouldn't present our bookkeeping as something the appliance said. And two smaller ones: - The repair fired on any device advertising slots, so the DW5000C -- four advertised, none ever loaded -- got a permanent warning nothing the owner did in Home Assistant could clear. It now waits until a payload has been seen, which is the only evidence that household uses downloaded programs. - The name-collision check read only the translation catalog, missing the device's own personal-course labels, which the select renders identically. --- custom_components/localthings/cloudcourse.py | 42 ++++++--- custom_components/localthings/config_flow.py | 38 ++++++-- custom_components/localthings/coordinator.py | 16 +++- .../registry/capabilities/laundry.py | 10 ++- .../localthings/registry/redact.py | 32 ++++++- custom_components/localthings/select.py | 21 +++-- custom_components/localthings/services.py | 9 +- .../localthings/translations/cs.json | 5 +- .../localthings/translations/de.json | 5 +- .../localthings/translations/en.json | 5 +- .../localthings/translations/es.json | 5 +- .../localthings/translations/it.json | 5 +- .../localthings/translations/ko.json | 5 +- .../localthings/translations/nl.json | 5 +- tests/test_cloud_courses.py | 21 +++++ tests/test_cloud_courses_flow.py | 89 +++++++++++++++++++ tests/test_select_display.py | 16 ++++ 17 files changed, 279 insertions(+), 50 deletions(-) diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index 3780b186..3d4af4a7 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -20,6 +20,13 @@ 2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one reason nothing here ever synthesizes one. +This is not washer-only, and nothing here assumes an appliance type: the +DW5000C dishwasher in ``tests/fixtures`` advertises four slots on the same +token. It also carries no payload token at all, so a device can name +programs whose payloads have never been observed -- ``advertised_slots`` +answers "which exist", the store answers "which are usable", and the two +are deliberately allowed to disagree. + What this module does and deliberately does not do -------------------------------------------------- The device advertises *which* slots exist but never what any of them is @@ -189,10 +196,13 @@ def __init__(self, stored_record=None) -> None: download, slots = _coerce(stored_record) self._download_course = download self._slots = slots - # Course codes seen while a one-time override was actually loaded -- + # Course codes seen at the moment a one-time override was *loaded* -- # candidates for "which course means Download on this board", pending # user confirmation (see download_candidates). self._candidates: dict[str, int] = {} + # Last one-time payload seen, so a load can be told from a poll that + # merely re-reports one. None means "nothing observed yet". + self._last_oneshot: str | None = None # -- learning --------------------------------------------------------- @@ -201,13 +211,20 @@ def observe(self, rep: dict) -> bool: 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. + on either token -- to be replayable forever after. + + The Download course code is only ever taken as a *candidate*, and + only at the moment the one-time payload actually *changes* to a + loaded value. That instant is the one the device is known to accept a + program on, so the course selected then is real evidence. Counting + every poll instead would rank by dwell time: tokens in this array are + replaced by prefix and never evicted, so a stale OneTimeCloudCourse_ + outlives its run and sits there through however many polls the + appliance spends on some ordinary course afterwards -- which is + exactly the course that would then be suggested. A candidate is still + never applied without confirmation in the options flow, but a + confident wrong suggestion is most of the way to a wrong write, and a + wrong write here starts a real wash cycle. """ options = rep.get("x.com.samsung.da.options") if not options: @@ -231,8 +248,9 @@ def observe(self, rep: dict) -> bool: changed = True oneshot = option_value(options, ONESHOT_PREFIX) course = option_value(options, COURSE_PREFIX) - if course and is_loaded(oneshot): + if course and is_loaded(oneshot) and oneshot != self._last_oneshot: self._candidates[course] = self._candidates.get(course, 0) + 1 + self._last_oneshot = oneshot return changed # -- reads ------------------------------------------------------------ @@ -242,9 +260,9 @@ def download_course(self) -> str | None: 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.""" + """Course codes seen at the moment a one-time program was loaded, + most-observed 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] diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 8f8b5191..b52f93f6 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -64,7 +64,7 @@ ) from .learned import persist as learned_persist from .learned import stored as learned_stored -from .registry.capabilities.laundry import cycle_options +from .registry.capabilities.laundry import cycle_options, personal_course_labels from .registry.subdevices import MAIN _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) @@ -967,17 +967,27 @@ def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: return {"base": "cloud_course_name_duplicate"} taken.add(name.casefold()) + # Belt and braces over the selector's own custom_value=False: this + # value becomes the Course_ token of a real write, so it is checked + # against the appliance's own course list here too, where the store + # is actually updated. + course = user_input.get("download_course") or None + if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)): + return {"base": "cloud_course_unknown_course"} + 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) + coord.set_cloud_download_course(course) return {} def _local_course_names(self, coord) -> set[str]: - """Translated display names of this appliance's own local courses. + """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. + Read through the same two sources the select renders from -- the + translation catalog, and the device's own personal-course labels + (laundry.washer_cycle_fallback) -- so the check matches what the user + will actually see side by side in the dropdown. A code neither source + names 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), @@ -990,7 +1000,14 @@ def _local_course_names(self, coord) -> set[str]: 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} + personal = personal_course_labels(resources) + names = set() + for code in cycle_options(resources): + if (catalogued := labels.get(code.lower())) is not None: + names.add(catalogued) + if (own := personal.get(code.upper())) is not None: + names.add(own) + return names def _cloud_courses_form( self, coord, rep, known, advertised, errors: dict[str, str] | None = None @@ -1007,7 +1024,10 @@ def _cloud_courses_form( # 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. + # first -- see CloudCourses.download_candidates. custom_value stays + # off deliberately: whatever lands here becomes the Course_ token of a + # real write, and a typed-in code the appliance doesn't offer would + # start something nobody chose. 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] @@ -1016,7 +1036,7 @@ def _cloud_courses_form( SelectSelector( SelectSelectorConfig( options=ordered, - custom_value=True, + custom_value=False, mode=SelectSelectorMode.DROPDOWN, ) ) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 2977fbe8..31214319 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -496,12 +496,22 @@ def _refresh_cloud_course_issue(self) -> None: 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. + + Gated on having seen at least one payload, which is the only + available evidence that this household uses downloaded programs at + all. Without it, an appliance that merely advertises slots -- the + DW5000C fixture reports four and has never loaded any -- would carry + a permanent warning about a feature its owner may never touch, and + nothing they do in Home Assistant could clear it. Running one + downloaded program is what turns the nudge on, and naming them is + what turns it off. """ 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: + record = self._cloud.snapshot() + pending = cloudcourse.undiscovered(rep, record) + needs_course = bool(cloudcourse.advertised_slots(rep)) and not record["download_course"] + if record["slots"] and (pending or needs_course): ir.async_create_issue( self.hass, DOMAIN, diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index c66618f9..19d69ebf 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -335,12 +335,16 @@ def option_write(prefix, new_value): # 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 +# that is what they are to a user -- on the appliance's own controls, +# "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. # +# Bound by whichever families declare it. Washers are where this was worked +# out, but a DW5000C dishwasher advertises the same token (see +# cloudcourse.py), so nothing below is washer-specific. +# # 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. diff --git a/custom_components/localthings/registry/redact.py b/custom_components/localthings/registry/redact.py index 1ed8e472..e9fd5ca2 100644 --- a/custom_components/localthings/registry/redact.py +++ b/custom_components/localthings/registry/redact.py @@ -39,6 +39,14 @@ _SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) +# Fields this integration merges onto a rep for its own use, which the +# device never reported (see coordinator.entity_resources). A diagnostics +# dump is meant to be exactly what the appliance said, so these are dropped +# rather than redacted -- keeping them would both misrepresent the device and +# publish data the user typed (cloud program names are user-supplied). +_SYNTHETIC_KEY_PREFIX = "x.localthings." + + def _is_sensitive_key(key: str) -> bool: lowered = key.lower() if lowered in _SENSITIVE_EXACT: @@ -46,8 +54,29 @@ def _is_sensitive_key(key: str) -> bool: return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) +def strip_synthetic(resources): + """Drop this integration's own merged-in fields, leaving only what the + appliance actually reported. + + Separate from redact_resources because the two answer different + questions: the debug read service wants the device's unredacted state + (serial and all -- that is the point of it) but still shouldn't present + our own bookkeeping as something the device said. + """ + if isinstance(resources, dict): + return { + key: strip_synthetic(value) + for key, value in resources.items() + if not key.startswith(_SYNTHETIC_KEY_PREFIX) + } + if isinstance(resources, list): + return [strip_synthetic(item) for item in resources] + return resources + + def redact_resources(resources): - """Recursively redact dict values whose key matches a sensitive substring. + """Recursively redact dict values whose key matches a sensitive substring, + and drop this integration's own synthetic fields entirely. Works on the shape produced by parse_device0_batch (dict[href, rep]) or any nested dict/list structure within a rep. @@ -56,6 +85,7 @@ def redact_resources(resources): return { key: (REDACTED if _is_sensitive_key(key) else redact_resources(value)) for key, value in resources.items() + if not key.startswith(_SYNTHETIC_KEY_PREFIX) } if isinstance(resources, list): return [redact_resources(item) for item in resources] diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 2ca918a2..d405cd5e 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -67,22 +67,29 @@ def _display(value, translation_key: str | None, fallback_fn=None): """ if not isinstance(value, str): return value + # No state table for this key: either the entity isn't translated at all, + # or its name is translated but its options deliberately aren't (an + # unrecognized course table, say). + uncatalogued = False if translation_key: known = translated_states("select", translation_key) if not known: - # No state table for this key: either the entity isn't translated - # at all, or its name is translated but its options deliberately - # aren't (an unrecognized course table, say). Give an explicit - # device-specific fallback the opportunity to make an opaque value - # readable; otherwise the raw device value remains the best choice. - if fallback_fn is None: - return value + uncatalogued = True elif translated := _translation_state(value, known): return translated if fallback_fn is not None: fallback = fallback_fn(value) if fallback is not None: return fallback + if uncatalogued: + # Nothing could name this value: not the catalog, and not a + # device-specific fallback (either absent, or present and declining + # to label this one). The raw device value is the best choice -- + # cosmetic reshaping below would only mangle an opaque code, turning + # a course '0E' into '0 E'. Keyed on the fallback's *result*, not on + # whether one was supplied: cycle_select always supplies one now, to + # label cloud programs, and it returns None for everything else. + return value if value.islower(): return value.replace("_", " ").title() return _CAMEL_BOUNDARY_RE.sub(" ", value) diff --git a/custom_components/localthings/services.py b/custom_components/localthings/services.py index c124b9d7..fa2c024e 100644 --- a/custom_components/localthings/services.py +++ b/custom_components/localthings/services.py @@ -24,6 +24,7 @@ from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE from .coordinator import LocalThingsCoordinator, normalize_href +from .registry.redact import strip_synthetic from .registry.subdevices import MAIN, Subdevice ATTR_HREF = "href" @@ -170,7 +171,13 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic # href: lets a user enumerate what exists without hammering the # device (see this module's docstring and the coordinator's # canonical_resources). - snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)} + # Stripped, not redacted: this response is meant to be what the + # appliance reported (unredacted -- that is the point of a debug + # read), but canonical_resources also carries fields this integration + # merged on for its own use (see entity_resources). + snapshot: dict[str, Any] = { + "resources": strip_synthetic(coordinator.canonical_resources(subdevice)) + } return cast(ServiceResponse, snapshot) # Same normalize-before-translate order as the write path above. diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index b6e5db27..7f158fe9 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Zadejte alespoň jedno pole k zápisu.", "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." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index f76fcb81..b9b5369d 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 16da3909..8c26a135 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Enter at least one field to write.", "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." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "This device isn't connected yet. Try again once it has loaded." diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 226b25ca..ca3b6a02 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -95,7 +95,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -104,7 +104,8 @@ "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 9735821a..88f0c116 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 675c2bed..d01a3702 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "쓸 필드를 하나 이상 입력하세요.", "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index e5b4eb64..04d28842 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1448,7 +1448,7 @@ }, "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.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index 29956f18..599827dc 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -207,6 +207,27 @@ def test_observing_the_same_rep_twice_changes_nothing(self): assert store.observe(rep) is True assert store.observe(rep) is False + def test_candidates_rank_by_program_loads_not_by_dwell_time(self): + """A stale OneTimeCloudCourse_ token outlives the run it belonged to, + so it is still reported through every poll the appliance spends on + whatever ordinary course comes next. Counting polls would rank that + course first and suggest it as the Download course -- and accepting + the suggestion would start a real wash cycle. Only the poll where the + payload actually changed is evidence.""" + store = cloudcourse.CloudCourses() + loaded = _rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]) + stale = _rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]) + store.observe(loaded) + for _ in range(200): # ~100 minutes sitting on a cotton cycle + store.observe(stale) + assert store.download_candidates() == ["87"] + + def test_a_second_distinct_load_is_counted(self): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) + store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{JEANS}"])) + assert store.download_candidates() == ["87"] + 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.""" diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index ab0b7382..272f7c26 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import json from typing import Any, cast import cbor2 @@ -21,6 +22,7 @@ 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 custom_components.localthings.registry.subdevices import MAIN from tests.conftest import _load_device from tests.test_subdevice_discovery import ENTRY_DATA @@ -320,3 +322,90 @@ async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistan # 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"] + + +async def test_no_repair_until_a_program_has_actually_been_seen(hass: HomeAssistant): + """The DW5000C advertises four downloaded programs and has never loaded + one, so nothing about them is learnable and no name field can be offered. + Warning its owner about a feature they may never use -- with no action + that could clear it -- is worse than staying quiet until they run one.""" + entry = _entry(hass) + coordinator = LocalThingsCoordinator(hass, entry) + resources = _load_device("dishwasher_dw5000c_cloud") + 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) + + assert cloudcourse.advertised_slots(coordinator.cloud_course_rep()) == ["8E", "8D", "8F", "02"] + assert coordinator.cloud_courses.snapshot()["slots"] == {} + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None + + +async def test_the_synthetic_field_never_reaches_diagnostics( + hass: HomeAssistant, + enable_custom_integrations, +): + """canonical_resources carries it so entity descriptors can read it, and + diagnostics reads canonical_resources -- so the drop has to happen at the + redaction boundary. A dump is meant to be what the appliance said, and + cloud program names are text the user typed.""" + from custom_components.localthings.diagnostics import ( + async_get_config_entry_diagnostics, + ) + + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Marc's weekend towels") + await _flush(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + dump = json.dumps(await async_get_config_entry_diagnostics(hass, entry)) + assert cloudcourse.FIELD not in dump + assert "Marc's weekend towels" not in dump + # The device's own tokens are still there -- only our field is dropped. + assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump + + +async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): + from custom_components.localthings.registry.redact import strip_synthetic + + coordinator = await _coordinator(hass) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Sports") + await _flush(hass) + + stripped = strip_synthetic(coordinator.canonical_resources(MAIN)) + assert cloudcourse.FIELD not in stripped[COURSE] + assert "x.com.samsung.da.options" in stripped[COURSE] + + +async def test_the_flow_rejects_a_download_course_the_device_does_not_offer( + hass: HomeAssistant, +): + """Whatever lands here becomes the Course_ token of a real write.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"}) + assert result["errors"] == {"base": "cloud_course_unknown_course"} + assert coordinator.cloud_courses.download_course() is None + assert coordinator.cloud_courses.named() == {} + + +async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAssistant): + """Personal course names come from the device, not the catalog, and the + select renders them the same way -- so they collide the same way.""" + coordinator = await _coordinator(hass) + # 'MyCo' as a personal course label on a code the device offers. + rep = dict(coordinator.resource("/wm/personalcourse/vs/0") or {}) + rep["x.com.samsung.da.courses"] = ["1C_01044D79436F"] + coordinator._observe.apply("/wm/personalcourse/vs/0", rep, source="poll") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_cloud_courses({"name_55": "MyCo", "download_course": "87"}) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} diff --git a/tests/test_select_display.py b/tests/test_select_display.py index 1a9165dd..212de652 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -50,3 +50,19 @@ def test_display_passes_through_non_string_values(): def test_display_uses_fallback_when_translation_has_no_state_table(): assert _display("69", "cycle", lambda value: f"Unknown (0x{value})") == ("Unknown (0x69)") + + +def test_uncatalogued_value_stays_raw_when_the_fallback_declines_it(): + """The "no state table" escape hatch keys off whether anything actually + named the value, not off whether a fallback was supplied. + + laundry.cycle_select always supplies one now (it labels cloud "Download" + programs) and returns None for everything else. Keyed on the fallback's + presence instead, every dryer/dishwasher/air-dresser on an unrecognized + course table would have its options and state cosmetically reshaped -- + course '0E' rendered '0 E' -- silently breaking automations and recorder + history.""" + assert _display("0E", "cycle", None) == "0E" + assert _display("0E", "cycle", lambda value: None) == "0E" + # A fallback that does name the value still wins. + assert _display("0E", "cycle", lambda value: f"Course {value}") == "Course 0E" From 2265c52c77d01f03020319016ef033255df2e99b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:30:23 +0000 Subject: [PATCH 05/14] laundry: apply cleanup review to the cloud-cycle branch Four parallel reviews (reuse, simplification, efficiency, altitude). The two that change behavior: - observe() could report "changed" on every poll forever, rewriting the config entry each time. If both tokens name the same slot with different payloads -- a downloaded program with its settings tweaked for one run is exactly that shape -- each pass wrote the default's blob then the one-shot's over it, so neither was ever already stored. On the SD-card installs this integration runs on, sustained entry rewrites are the one cost here that bites. The end state is stable, so "changed" is now start-vs-end, not per-assignment. - The write path copied every tracked href to read one rep, walking past the accessor added to avoid exactly that. New entity_rep() does the merge for a single href; cycle_write drops the resources parameter it never used. Structure: - device_resources() is a second accessor giving the pure device view, used by diagnostics and the debug read service. That deletes strip_synthetic, the _SYNTHETIC_KEY_PREFIX convention and the redact filter added last commit: "a dump is what the device said" is now which method you call rather than something every future exporter has to remember. - apply_cloud_courses() is the single mutation path. The flow was reaching past the coordinator into the store and relying on a later call to persist and invalidate for it; nine names are also now one entry write, not nine. - option_value/hex_pairs move to capabilities/common.py. The duplicate's stated reason -- that the coordinator shouldn't import from registry.capabilities -- was simply false; it already does, and so does learned.py. The real constraint is narrower: laundry.py imports cloudcourse, so the reverse would be a cycle. Dropped rather than kept: - The cloud-vs-translated-local-course name check, and catalog. translated_state_labels with it. The catalog this process can read is English while the dropdown is localized in the frontend, so it rejected "Cotton" for a German user seeing "Baumwolle" and missed the real collision when they typed "Baumwolle" -- wrong in both directions outside one locale, against an outcome option ordering already makes deterministic. The checks that survive compare strings that are the same in every locale: the user's own names, and the device's personal-course labels. - stored(), clear()/forget_cloud_courses(), blob(), download_course() -- no production callers. stored() was a template artifact whose docstring described a caller that cannot exist here. Diagnostics gains a cloud_courses block, which the store was missing next to learned_modes -- payloads and which slots are named, but not the names themselves, since those are the user's words and dumps get pasted publicly. Kept against one reviewer's advice: option_tokens (two others called generalizing option_write the right direction) and select._display's uncatalogued branch, which names a condition the old fallback-is-None proxy only got right by accident. Deferred: making the store per-subdevice. It is MAIN-only today and no device seen advertises cloud programs elsewhere; the limitation is now documented where it is made. --- custom_components/localthings/catalog.py | 18 ---- custom_components/localthings/cloudcourse.py | 84 +++++++++---------- custom_components/localthings/config_flow.py | 66 +++++++-------- custom_components/localthings/coordinator.py | 82 +++++++++++++----- custom_components/localthings/diagnostics.py | 19 ++++- .../registry/capabilities/common.py | 22 +++++ .../registry/capabilities/laundry.py | 18 +--- .../localthings/registry/redact.py | 32 +------ custom_components/localthings/select.py | 36 ++++---- custom_components/localthings/services.py | 12 +-- tests/test_cloud_courses.py | 16 ++-- tests/test_cloud_courses_flow.py | 60 +++++-------- 12 files changed, 219 insertions(+), 246 deletions(-) diff --git a/custom_components/localthings/catalog.py b/custom_components/localthings/catalog.py index 9020e571..68b6208f 100644 --- a/custom_components/localthings/catalog.py +++ b/custom_components/localthings/catalog.py @@ -48,21 +48,3 @@ 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 index 3d4af4a7..01afffa3 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -52,6 +52,7 @@ import threading from .const import CONF_CLOUD_COURSES +from .registry.capabilities.common import hex_pairs, option_value COURSE_HREF = "/course/vs/0" @@ -94,33 +95,29 @@ def _hex_bytes(blob): int(blob, 16) except ValueError: return [] - return [blob[i : i + 2].upper() for i in range(0, len(blob), 2)] + return hex_pairs(blob.upper()) 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) + return _slot_and_loaded(blob)[1] 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] + slot, loaded = _slot_and_loaded(blob) + return slot if loaded else None -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 _slot_and_loaded(blob) -> tuple[str | None, bool]: + """Both answers off one parse -- the public pair above needs the same + byte split, and observe() asks for both about the same payload.""" + parts = _hex_bytes(blob) + if not parts: + return None, False + if "".join(parts[:2]) == _SENTINEL_PREFIX: + return None, False + return parts[_SLOT_BYTE], True def advertised_slots(rep) -> list[str]: @@ -131,10 +128,9 @@ def advertised_slots(rep) -> list[str]: 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)) + return list(dict.fromkeys(hex_pairs(raw.upper()))) def supports_cloud_courses(rep) -> bool: @@ -169,13 +165,6 @@ def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]: 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.""" @@ -230,8 +219,20 @@ def observe(self, rep: dict) -> bool: if not options: return False known_slots = advertised_slots(rep) - changed = False + oneshot = option_value(options, ONESHOT_PREFIX) with self._lock: + # Compared once, at the end, against where this pass started -- + # not set per assignment. The two tokens can name the same slot + # with different payloads (a downloaded program with its settings + # tweaked for one run is exactly that shape), and a per-assignment + # flag would then report a change on every single poll forever: + # each pass writes the default's payload and then the one-shot's + # over it, so neither is ever "already stored". Every one of those + # reports rewrites the config entry, which on the SD-card installs + # this integration runs on is the one cost here that really bites. + # The end state is stable (the one-shot is written last and wins), + # so comparing start to end settles after the first pass. + before = {slot: record["blob"] for slot, record in self._slots.items()} for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX): blob = option_value(options, prefix) slot = slot_of(blob) @@ -242,11 +243,10 @@ def observe(self, rep: dict) -> bool: record = self._slots.get(slot) if record is None: self._slots[slot] = {"blob": blob.upper(), "name": ""} - changed = True - elif record["blob"] != blob.upper(): + else: record["blob"] = blob.upper() - changed = True - oneshot = option_value(options, ONESHOT_PREFIX) + changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()} + course = option_value(options, COURSE_PREFIX) if course and is_loaded(oneshot) and oneshot != self._last_oneshot: self._candidates[course] = self._candidates.get(course, 0) + 1 @@ -255,10 +255,6 @@ def observe(self, rep: dict) -> bool: # -- reads ------------------------------------------------------------ - def download_course(self) -> str | None: - with self._lock: - return self._download_course - def download_candidates(self) -> list[str]: """Course codes seen at the moment a one-time program was loaded, most-observed first -- what the options flow offers as the likely @@ -267,11 +263,6 @@ def download_candidates(self) -> list[str]: 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 @@ -299,7 +290,7 @@ def view(self) -> dict: "programs": { slot: {"blob": record["blob"], "name": record["name"]} for slot, record in self._slots.items() - if record["name"] + if self._is_usable(record) }, } @@ -315,11 +306,12 @@ def set_name(self, slot: str, name: str) -> None: if record is not None: record["name"] = name.strip() - def clear(self) -> None: - with self._lock: - self._download_course = None - self._slots = {} - self._candidates = {} + @staticmethod + def _is_usable(record) -> bool: + """A slot is offerable once it has a name. The device supplies the + payload; only the user can supply the label, so this is the whole + rule and it is stated once.""" + return bool(record["name"]) def undiscovered(rep: dict, record: dict) -> list[str]: diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index b52f93f6..1f196a55 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -35,7 +35,6 @@ ) from . import cloudcourse -from .catalog import translated_state_labels from .const import ( CLIENTHELLO_PROBE_RETRIES, CLIENTHELLO_PROBE_TIMEOUT_S, @@ -945,21 +944,31 @@ async def async_step_cloud_courses( 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, known, advertised, errors=errors) - return self._cloud_courses_form(coord, rep, known, advertised) + return self._cloud_courses_form(coord, 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. + The select maps a chosen label back to a raw value by matching display + text, so two options sharing a label resolve to whichever comes first. + Two sources of collision are checkable here and both are rejected: + the user's own names against each other, and against the appliance's + personal-course labels, which the device reports verbatim and the + select renders as-is. + + A collision with a *translated* local course name is deliberately not + checked. The catalog this process can read is English (catalog.py), + while what the user actually sees is localized in the frontend -- so + checking it would reject "Cotton" for a German user whose dropdown + says "Baumwolle", and still miss the real collision when they type + "Baumwolle". Wrong in both directions outside one locale, against an + outcome the option ordering already makes deterministic (local + courses come first, so a shared label resolves to the real cycle). """ 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)} + taken = {name.casefold() for name in self._device_course_names(coord)} for name in names.values(): if not name: continue @@ -975,42 +984,23 @@ def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)): return {"base": "cloud_course_unknown_course"} - for slot, name in names.items(): - coord.cloud_courses.set_name(slot, name) - coord.set_cloud_download_course(course) + coord.apply_cloud_courses(names, course) return {} - def _local_course_names(self, coord) -> set[str]: - """Display names of this appliance's own local courses. + def _device_course_names(self, coord) -> set[str]: + """Course names this appliance reports itself. - Read through the same two sources the select renders from -- the - translation catalog, and the device's own personal-course labels - (laundry.washer_cycle_fallback) -- so the check matches what the user - will actually see side by side in the dropdown. A code neither source - names has no display name to collide with. + Only the personal-course labels: the device sends these as text and + the select renders them unchanged, so they are the same string in + every locale and can be compared against safely. See the caller for + why translated course names are not included. """ - 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 {} + resources = coord.canonical_resources(MAIN) personal = personal_course_labels(resources) - names = set() - for code in cycle_options(resources): - if (catalogued := labels.get(code.lower())) is not None: - names.add(catalogued) - if (own := personal.get(code.upper())) is not None: - names.add(own) - return names + return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))} def _cloud_courses_form( - self, coord, rep, known, advertised, errors: dict[str, str] | None = None + self, coord, known, advertised, errors: dict[str, str] | None = None ) -> ConfigFlowResult: store = coord.cloud_courses record = store.snapshot() diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 31214319..c2de6dab 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -325,14 +325,22 @@ def entity_resources(self) -> dict[str, dict]: 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. + synthetic field can never be polled over or written to the device -- + `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. + + MAIN only, by construction: cloudcourse.COURSE_HREF is a canonical + href and this snapshot is keyed by actual ones, so a composite + appliance's second course resource (//course/vs/0 on the + one-body washer-dryer) is not merged and not learned from. No device + seen so far advertises cloud programs on anything but MAIN; making + this per-subdevice means keying the store by actual href the way + LearnedModes does, and migrating the persisted shape. """ - snapshot = self._cache.snapshot() + snapshot = self.last_resources rep = snapshot.get(cloudcourse.COURSE_HREF) if rep is None: return snapshot @@ -342,6 +350,30 @@ def entity_resources(self) -> dict[str, dict]: snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view} return snapshot + def entity_rep(self, href: str) -> dict: + """One href's rep as descriptors see it -- `resource()` plus the + merge `entity_resources` would have applied. Exists so the write path + doesn't copy every tracked href to read one rep, which is the very + thing `resource()` was added to avoid.""" + rep = self.resource(href) + if href != cloudcourse.COURSE_HREF or not rep: + return rep + view = self._cloud.view() + return {**rep, cloudcourse.FIELD: view} if view else rep + + def device_resources(self, subdevice: Subdevice) -> dict[str, dict]: + """`subdevice`'s canonical view of exactly what the appliance + reported -- no integration state merged in. + + The counterpart to canonical_resources for everything that *exports* + resources rather than rendering entities from them: diagnostics and + the debug read service. Keeping this a separate call rather than + filtering the merged view downstream is what makes "a dump is what the + device said" a property of which method you call, instead of a + convention every future exporter has to remember. + """ + return canonical_view(subdevice, self.last_resources, self.subdevices) + 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 @@ -466,23 +498,30 @@ def _persist_cloud_courses(self) -> None: @property def cloud_courses(self) -> CloudCourses: - """The discovered-program store, for the options flow.""" + """The discovered-program store, for reads (snapshot/view/candidates). + + Mutations go through apply_cloud_courses below, not through this -- + the store itself doesn't persist or invalidate, and `_canonical_cache` + now depends on its contents, so a caller that mutates it directly + leaves entity options stale with no error to say so. + """ 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 apply_cloud_courses(self, names: dict[str, str], download_course: str | None) -> None: + """The one mutation path for the cloud-program store (issue #342). - def forget_cloud_courses(self) -> None: - self._cloud.clear() + Takes the whole submission at once so a nine-program naming pass is + one config-entry write rather than nine, and so persistence, the + canonical-view invalidation and the Repairs refresh can't be done for + one half of a change and skipped for the other. + """ + for slot, name in names.items(): + self._cloud.set_name(slot, name) + self._cloud.set_download_course(download_course) self._persist_cloud_courses() @callback @@ -1316,11 +1355,12 @@ async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> N if write_fn is None: return href = bound_entity.href - # Through entity_resources(), not the bare cache: write_fn must see - # the same rep exists_fn/rep_fn were handed, including the merged + # Through entity_rep(), 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 {} + # every href that field doesn't touch, and without copying the whole + # tree to read one rep. + rep = self.entity_rep(href 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/diagnostics.py b/custom_components/localthings/diagnostics.py index bce32789..15fdd8aa 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.loader import async_get_integration +from . import cloudcourse from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .registry.redact import redact_resources @@ -32,6 +33,7 @@ async def async_get_config_entry_diagnostics( # disk (listdir + open + read_text), which trips HA's event-loop blocking # detector when called inline here. Offload it to the executor. stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local") + cloud_courses = coordinator.cloud_courses.snapshot() # /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured # below, so they'd otherwise never reach an issue report. /oic/d's `rt` @@ -54,7 +56,7 @@ def _subdevice_diag(su) -> dict: # than redacting /information/vs/0 again -- modelNum never matches # redact.py's substring rules, so the value is the same either way. matching = [b for b in coordinator.bound if b.subdevice == su] - res = redact_resources(coordinator.canonical_resources(su)) + res = redact_resources(coordinator.device_resources(su)) return { "kind": su.kind, "key": su.key, @@ -88,7 +90,7 @@ def _subdevice_diag(su) -> dict: # /mode/vs/0 under no attribution. Each sibling reports its own # resources in `subdevices` below instead. For a device with no # subdevices, this is byte-identical to `last_resources`. - "resources": redact_resources(coordinator.canonical_resources(MAIN)), + "resources": redact_resources(coordinator.device_resources(MAIN)), # Sibling indoor subdevices discovered on this connection (issue # #177). subdeviceIdList (the UUID a prefixed subdevice's key comes # from) is deliberately NOT redacted here, unlike elsewhere in @@ -137,6 +139,19 @@ def _subdevice_diag(su) -> dict: "enabled": coordinator.learning_enabled, "codes": coordinator.learned_snapshot(), }, + # Cloud "Download" programs discovered on this device (issue #342), + # reported separately for the same reason as learned_modes above. + # Payloads are the useful part for triage -- they are the only record + # of what a downloaded program contains. The names are not included: + # they are the user's own words, and a dump gets pasted into public + # issues. Which slots are named is still visible, which is all the + # triage question ("is this set up?") actually needs. + "cloud_courses": { + "advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()), + "download_course": cloud_courses["download_course"], + "payloads": {slot: rec["blob"] for slot, rec in cloud_courses["slots"].items()}, + "named_slots": sorted(s for s, rec in cloud_courses["slots"].items() if rec["name"]), + }, "integration_version": integration.version, "smartthings_local_version": stl_version, "observe_mode": coordinator.observe_mode, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 17a30575..dfab158b 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -136,6 +136,28 @@ def _active_alarm_codes(items): return ", ".join(codes) if codes else "none" +def hex_pairs(codes): + """'1C1D21...' -> ['1C', '1D', '21', ...].""" + return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)] + + +def option_value(options, prefix): + """Find `_` in an options[] array and return . + + Lives here rather than in laundry.py, which is where it grew, because + cloudcourse.py needs it too and laundry.py imports *that* -- so the + reverse import would be a module cycle. The coordinator already imports + from this module, so nothing about the dependency direction is unusual; + it is specifically the laundry/cloudcourse pair that can't reach each + other. Anchored at position 0 so 'Course_' never matches + 'CloudCourse_'/'OneTimeCloudCourse_'. + """ + for o in options or []: + if isinstance(o, str) and o.startswith(prefix + "_"): + return o.split("_", 1)[1] + return None + + def merge_options_field(cached, new_tokens): """Merge freshly-written `_` tokens into a cached x.com.samsung.da.options[]-style array the same way the device itself diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 19d69ebf..db959e8c 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -27,6 +27,7 @@ from ...catalog import has_entity_translation from ..capability import Capability from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc +from .common import hex_pairs, option_value _LED_LEVELS = ("Low", "High") _SOUND_MODES = ("voice", "tone", "mute") @@ -199,11 +200,6 @@ def _sound_mode_write(p, rep, href=None): # boards expose the same /course/vs/0 options contract. -def hex_pairs(codes): - """'1C1D21...' -> ['1C', '1D', '21', ...].""" - return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)] - - def parse_edit_course_list(raw): """'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...].""" if not isinstance(raw, str) or "_" not in raw: @@ -219,14 +215,6 @@ def cycle_options(resources): return _course_codes_from_supported_options(resources.get("/course/vs/0") or {}) -def option_value(options, prefix): - """Find `_` in the options array and return .""" - for o in options or []: - if isinstance(o, str) and o.startswith(prefix + "_"): - return o.split("_", 1)[1] - return None - - # Drum Clean+ maintenance tracking, from the same options[] array as the # selected course -- shared by washer.py (issue #9) and dryer.py (issue # #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens. @@ -404,7 +392,7 @@ def cloud_current(rep): return f"{cloudcourse.RAW_PREFIX}{slot}" -def cycle_write(p, rep, href=None, resources=None): +def cycle_write(p, rep, href=None): if not rep.get("x.com.samsung.da.options"): return None if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX): @@ -521,7 +509,7 @@ def key(resources): return candidate if has_entity_translation("select", candidate) else "cycle" def options(resources): - rep = resources.get("/course/vs/0") or {} + rep = resources.get(cloudcourse.COURSE_HREF) 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 diff --git a/custom_components/localthings/registry/redact.py b/custom_components/localthings/registry/redact.py index e9fd5ca2..1ed8e472 100644 --- a/custom_components/localthings/registry/redact.py +++ b/custom_components/localthings/registry/redact.py @@ -39,14 +39,6 @@ _SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) -# Fields this integration merges onto a rep for its own use, which the -# device never reported (see coordinator.entity_resources). A diagnostics -# dump is meant to be exactly what the appliance said, so these are dropped -# rather than redacted -- keeping them would both misrepresent the device and -# publish data the user typed (cloud program names are user-supplied). -_SYNTHETIC_KEY_PREFIX = "x.localthings." - - def _is_sensitive_key(key: str) -> bool: lowered = key.lower() if lowered in _SENSITIVE_EXACT: @@ -54,29 +46,8 @@ def _is_sensitive_key(key: str) -> bool: return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) -def strip_synthetic(resources): - """Drop this integration's own merged-in fields, leaving only what the - appliance actually reported. - - Separate from redact_resources because the two answer different - questions: the debug read service wants the device's unredacted state - (serial and all -- that is the point of it) but still shouldn't present - our own bookkeeping as something the device said. - """ - if isinstance(resources, dict): - return { - key: strip_synthetic(value) - for key, value in resources.items() - if not key.startswith(_SYNTHETIC_KEY_PREFIX) - } - if isinstance(resources, list): - return [strip_synthetic(item) for item in resources] - return resources - - def redact_resources(resources): - """Recursively redact dict values whose key matches a sensitive substring, - and drop this integration's own synthetic fields entirely. + """Recursively redact dict values whose key matches a sensitive substring. Works on the shape produced by parse_device0_batch (dict[href, rep]) or any nested dict/list structure within a rep. @@ -85,7 +56,6 @@ def redact_resources(resources): return { key: (REDACTED if _is_sensitive_key(key) else redact_resources(value)) for key, value in resources.items() - if not key.startswith(_SYNTHETIC_KEY_PREFIX) } if isinstance(resources, list): return [redact_resources(item) for item in resources] diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index d405cd5e..1038f003 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -67,28 +67,20 @@ def _display(value, translation_key: str | None, fallback_fn=None): """ if not isinstance(value, str): return value - # No state table for this key: either the entity isn't translated at all, - # or its name is translated but its options deliberately aren't (an - # unrecognized course table, say). - uncatalogued = False - if translation_key: - known = translated_states("select", translation_key) - if not known: - uncatalogued = True - elif translated := _translation_state(value, known): - return translated - if fallback_fn is not None: - fallback = fallback_fn(value) - if fallback is not None: - return fallback - if uncatalogued: - # Nothing could name this value: not the catalog, and not a - # device-specific fallback (either absent, or present and declining - # to label this one). The raw device value is the best choice -- - # cosmetic reshaping below would only mangle an opaque code, turning - # a course '0E' into '0 E'. Keyed on the fallback's *result*, not on - # whether one was supplied: cycle_select always supplies one now, to - # label cloud programs, and it returns None for everything else. + known = translated_states("select", translation_key) if translation_key else frozenset() + if translated := _translation_state(value, known): + return translated + if fallback_fn is not None and (fallback := fallback_fn(value)) is not None: + return fallback + if translation_key and not known: + # No state table for this key: either the entity isn't translated at + # all, or its name is translated but its options deliberately aren't + # (an unrecognized course table, say). Nothing named this value, so + # the raw device value is the best choice -- the cosmetic reshaping + # below would only mangle an opaque code, turning a course '0E' into + # '0 E'. Reached only when the fallback *declined* the value, not + # merely when none was supplied: cycle_select always supplies one now + # (it labels cloud programs) and returns None for everything else. return value if value.islower(): return value.replace("_", " ").title() diff --git a/custom_components/localthings/services.py b/custom_components/localthings/services.py index fa2c024e..8220c6ee 100644 --- a/custom_components/localthings/services.py +++ b/custom_components/localthings/services.py @@ -24,7 +24,6 @@ from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE from .coordinator import LocalThingsCoordinator, normalize_href -from .registry.redact import strip_synthetic from .registry.subdevices import MAIN, Subdevice ATTR_HREF = "href" @@ -171,13 +170,10 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic # href: lets a user enumerate what exists without hammering the # device (see this module's docstring and the coordinator's # canonical_resources). - # Stripped, not redacted: this response is meant to be what the - # appliance reported (unredacted -- that is the point of a debug - # read), but canonical_resources also carries fields this integration - # merged on for its own use (see entity_resources). - snapshot: dict[str, Any] = { - "resources": strip_synthetic(coordinator.canonical_resources(subdevice)) - } + # device_resources, not canonical_resources: this response is what + # the appliance reported, without the fields this integration merges + # on for its own use (see coordinator.entity_resources). + snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)} return cast(ServiceResponse, snapshot) # Same normalize-before-translate order as the write path above. diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index 599827dc..a4cab657 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -90,8 +90,8 @@ def test_reporter_dump_advertises_nine_and_learns_both_loaded_blobs(self): store = cloudcourse.CloudCourses() assert store.observe(rep) is True - assert store.blob("55") == SPORTS - assert store.blob("6B") == JEANS + assert store.snapshot()["slots"]["55"]["blob"] == SPORTS + assert store.snapshot()["slots"]["6B"]["blob"] == JEANS # Learned but unnamed -- nothing is offerable yet. assert store.named() == {} assert store.view() == {} @@ -102,7 +102,7 @@ def test_reporter_dump_proposes_its_download_course(self): store.observe(rep) assert store.download_candidates() == ["87"] # A candidate is never used until confirmed. - assert store.download_course() is None + assert store.snapshot()["download_course"] is None def test_wa55_learns_its_saved_program_but_not_the_sentinel(self): rep = _load_device("washer_wa55a7700av")["/course/vs/0"] @@ -110,8 +110,8 @@ def test_wa55_learns_its_saved_program_but_not_the_sentinel(self): store = cloudcourse.CloudCourses() store.observe(rep) - assert store.blob("59") == "001C590549164D114A224C2037F0AC22" - assert store.blob("01") is None # the FFFF sentinel's byte 2 + assert store.snapshot()["slots"]["59"]["blob"] == "001C590549164D114A224C2037F0AC22" + assert "01" not in store.snapshot()["slots"] # 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 @@ -160,7 +160,7 @@ def test_byte_three_is_not_part_of_a_programs_identity(self): for blob in (b06c, b048): store = cloudcourse.CloudCourses() store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"])) - assert store.blob("0A") == blob + assert store.snapshot()["slots"]["0A"]["blob"] == blob def test_a_sentinel_slot_byte_is_not_the_current_course(self): """The two sentinels in the corpus disagree about this -- WA55's byte @@ -192,14 +192,14 @@ def test_only_advertised_slots_are_recorded(self): offers.""" store = cloudcourse.CloudCourses() store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"])) - assert store.blob("6B") is None + assert "6B" not in store.snapshot()["slots"] 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 + assert store.snapshot()["slots"]["55"]["blob"] == rewritten def test_observing_the_same_rep_twice_changes_nothing(self): store = cloudcourse.CloudCourses() diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 272f7c26..3914d202 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -80,8 +80,8 @@ async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistan entry = _entry(hass) coordinator = await _coordinator(hass, entry) - assert coordinator.cloud_courses.blob("55") == SPORTS - assert coordinator.cloud_courses.blob("6B") == JEANS + assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS + assert coordinator.cloud_courses.snapshot()["slots"]["6B"]["blob"] == JEANS # Survives a restart -- the payload is only visible while loaded. assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS @@ -96,7 +96,7 @@ async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant): source="optimistic", ) await _flush(hass) - assert coordinator.cloud_courses.blob("55") is None + assert "55" not in coordinator.cloud_courses.snapshot()["slots"] async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant): @@ -109,8 +109,7 @@ async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: H 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") + coordinator.apply_cloud_courses({"55": "Sports"}, "87") await _flush(hass) assert "cloud:55" in _cycle_options(coordinator) @@ -118,7 +117,7 @@ async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant) # 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") + coordinator.apply_cloud_courses({"6B": "Jeans"}, "87") await _flush(hass) assert _cycle_state(coordinator) == "cloud:6B" @@ -128,8 +127,7 @@ async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeA 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") + coordinator.apply_cloud_courses({"55": "Sports"}, "87") await _flush(hass) assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE] @@ -142,8 +140,7 @@ async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant) 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") + coordinator.apply_cloud_courses({"55": "Sports"}, "87") await _flush(hass) sent: list[tuple[list[str], bytes]] = [] @@ -184,7 +181,6 @@ async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssista 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( @@ -195,7 +191,7 @@ async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssista ] } ) - coordinator.set_cloud_course_name(slot, f"Program {slot}") + coordinator.apply_cloud_courses({slot: f"Program {slot}"}, "87") await _flush(hass) assert registry.async_get_issue(DOMAIN, issue_id) is None @@ -219,7 +215,7 @@ 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 + assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS # --------------------------------------------------------------------------- @@ -276,19 +272,6 @@ async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant) 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) @@ -358,27 +341,30 @@ async def test_the_synthetic_field_never_reaches_diagnostics( entry = _entry(hass) coordinator = await _coordinator(hass, entry) - coordinator.set_cloud_download_course("87") - coordinator.set_cloud_course_name("55", "Marc's weekend towels") + coordinator.apply_cloud_courses({"55": "Marc's weekend towels"}, "87") await _flush(hass) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - dump = json.dumps(await async_get_config_entry_diagnostics(hass, entry)) + diag = await async_get_config_entry_diagnostics(hass, entry) + dump = json.dumps(diag) assert cloudcourse.FIELD not in dump - assert "Marc's weekend towels" not in dump - # The device's own tokens are still there -- only our field is dropped. + # The device's own tokens are still there -- resources stays what it said. assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump + # The store is reported in its own block, so a triager can see the + # payloads -- but not the user's chosen names. + assert "Marc's weekend towels" not in dump + assert diag["cloud_courses"]["payloads"]["55"] == SPORTS + assert diag["cloud_courses"]["named_slots"] == ["55"] + assert diag["cloud_courses"]["download_course"] == "87" -async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): - from custom_components.localthings.registry.redact import strip_synthetic +async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): coordinator = await _coordinator(hass) - coordinator.set_cloud_download_course("87") - coordinator.set_cloud_course_name("55", "Sports") + coordinator.apply_cloud_courses({"55": "Sports"}, "87") await _flush(hass) - stripped = strip_synthetic(coordinator.canonical_resources(MAIN)) + stripped = coordinator.device_resources(MAIN) assert cloudcourse.FIELD not in stripped[COURSE] assert "x.com.samsung.da.options" in stripped[COURSE] @@ -392,7 +378,7 @@ async def test_the_flow_rejects_a_download_course_the_device_does_not_offer( result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"}) assert result["errors"] == {"base": "cloud_course_unknown_course"} - assert coordinator.cloud_courses.download_course() is None + assert coordinator.cloud_courses.snapshot()["download_course"] is None assert coordinator.cloud_courses.named() == {} From cef187b7af8e580112560be4bdd84a069471aa30 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:33:28 +0000 Subject: [PATCH 06/14] diagnostics: report discovered cloud cycles in full, names included Trimming the names out of the dump last commit was the wrong call. Half of what goes wrong with this feature is a configuration question -- which programs got named, which Download course was confirmed, whether a payload was ever captured for a slot the device advertises -- and none of that is answerable from the payloads alone. A report saying "my download cycle isn't showing up" is exactly the case that needs it. The names are still the user's own words, so this block stays the one place they appear; they reach a dump only because its owner chose to download and share it. `resources` is unaffected either way -- it goes on reporting exactly what the appliance said, via device_resources(). --- custom_components/localthings/diagnostics.py | 21 +++++++++-------- tests/test_cloud_courses_flow.py | 24 ++++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 15fdd8aa..6141e35c 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -140,17 +140,20 @@ def _subdevice_diag(su) -> dict: "codes": coordinator.learned_snapshot(), }, # Cloud "Download" programs discovered on this device (issue #342), - # reported separately for the same reason as learned_modes above. - # Payloads are the useful part for triage -- they are the only record - # of what a downloaded program contains. The names are not included: - # they are the user's own words, and a dump gets pasted into public - # issues. Which slots are named is still visible, which is all the - # triage question ("is this set up?") actually needs. + # reported separately from `resources` for the same reason as + # learned_modes above -- the dump there stays exactly what the device + # said, and this is what the integration made of it. + # + # Reported in full, names included. Half of what can go wrong with + # this feature is a configuration question -- which programs got + # named, which Download course was confirmed, whether a payload was + # ever captured for a slot the device advertises -- and none of that + # is answerable from the payloads alone. The names are the user's own + # words, so this is the one place they appear; they reach a dump only + # because its owner chose to download and share it. "cloud_courses": { "advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()), - "download_course": cloud_courses["download_course"], - "payloads": {slot: rec["blob"] for slot, rec in cloud_courses["slots"].items()}, - "named_slots": sorted(s for s, rec in cloud_courses["slots"].items() if rec["name"]), + **cloud_courses, }, "integration_version": integration.version, "smartthings_local_version": stl_version, diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 3914d202..440dc50f 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -346,17 +346,21 @@ async def test_the_synthetic_field_never_reaches_diagnostics( hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, entry) - dump = json.dumps(diag) - assert cloudcourse.FIELD not in dump + assert cloudcourse.FIELD not in json.dumps(diag["resources"]) # The device's own tokens are still there -- resources stays what it said. - assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump - - # The store is reported in its own block, so a triager can see the - # payloads -- but not the user's chosen names. - assert "Marc's weekend towels" not in dump - assert diag["cloud_courses"]["payloads"]["55"] == SPORTS - assert diag["cloud_courses"]["named_slots"] == ["55"] - assert diag["cloud_courses"]["download_course"] == "87" + assert "CloudExtraCourse_0A5C286B2D0C55301A" in json.dumps(diag["resources"]) + + # The store is reported in full in its own block: what was discovered, + # what the user named it, and which course they confirmed. Half of what + # goes wrong here is a configuration question and none of it is + # answerable from the payloads alone. + cloud = diag["cloud_courses"] + assert cloud["advertised_slots"] == ["0A", "5C", "28", "6B", "2D", "0C", "55", "30", "1A"] + assert cloud["download_course"] == "87" + assert cloud["slots"]["55"] == {"blob": SPORTS, "name": "Marc's weekend towels"} + # A slot seen but never named is reported too -- that gap is the thing a + # "why isn't my download cycle showing up" report needs to reveal. + assert cloud["slots"]["6B"] == {"blob": JEANS, "name": ""} async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): From bee4466b45387b819e18443db8ed9a3eeb8af2bf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:45:14 +0000 Subject: [PATCH 07/14] translations: localize the download-cycle strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eight new strings shipped as English placeholders in every non-English catalog. Translated for cs/de/es/it/ko/nl. Where a locale's course table already names the Download course itself, that existing term is reused rather than a fresh coinage -- Korean's 다운로드 코스 is the catalog's own translation of course code 17, so the options flow now says what the appliance display says. German and Dutch had no equally distinctive existing term and build on the adjective already used for the downloaded state. Counts are not pluralized. The strings have no plural support and the placeholders are raw numbers, so Czech, Italian and Dutch use the plural form regardless of count -- the same simplification the rest of these catalogs already make. --- .../localthings/translations/cs.json | 16 ++++++++-------- .../localthings/translations/de.json | 16 ++++++++-------- .../localthings/translations/es.json | 16 ++++++++-------- .../localthings/translations/it.json | 16 ++++++++-------- .../localthings/translations/ko.json | 16 ++++++++-------- .../localthings/translations/nl.json | 16 ++++++++-------- 6 files changed, 48 insertions(+), 48 deletions(-) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 7f158fe9..ad69fae2 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1406,7 +1406,7 @@ "title": "Možnosti LocalThings", "menu_options": { "settings": "Nastavení zápisu pro dálkové ovládání", - "cloud_courses": "Download cycles", + "cloud_courses": "Stažené cykly", "forget_learned_modes": "Zapomenout zapamatované režimy", "debug_write": "Ladění: zápis do prostředku" } @@ -1447,18 +1447,18 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "Stažené cykly", + "description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.", "data": { - "download_course": "Download cycle course code" + "download_course": "Kód programu „Stažený program“" } } }, "error": { "empty_payload": "Zadejte alespoň jedno pole k zápisu.", "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.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "Dva cykly mají stejný název. Názvy musí být jedinečné.", + "cloud_course_unknown_course": "Tento program zařízení nenabízí. Vyberte jej ze seznamu." }, "abort": { "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." @@ -1470,8 +1470,8 @@ "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." + "title": "Stažené cykly nejsou nastaveny pro {device_name}", + "description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů." } }, "exceptions": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index b9b5369d..2a5e023e 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1406,7 +1406,7 @@ "title": "LocalThings-Optionen", "menu_options": { "settings": "Geräteeinstellungen", - "cloud_courses": "Download cycles", + "cloud_courses": "Download-Programme", "debug_write": "Debug: In eine Ressource schreiben", "forget_learned_modes": "Gemerkte Modi vergessen" } @@ -1447,18 +1447,18 @@ "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": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "Download-Programme", + "description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.", "data": { - "download_course": "Download cycle course code" + "download_course": "Programmcode des Download-Programms" } } }, "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "Zwei Programme haben denselben Namen. Namen müssen eindeutig sein.", + "cloud_course_unknown_course": "Dieses Programm wird von diesem Gerät nicht angeboten. Wählen Sie eines aus der Liste." }, "abort": { "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." @@ -1470,8 +1470,8 @@ "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." + "title": "Heruntergeladene Programme für {device_name} nicht eingerichtet", + "description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen." } }, "exceptions": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index ca3b6a02..5ae59522 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -53,7 +53,7 @@ "title": "Opciones de LocalThings", "menu_options": { "settings": "Ajustes del dispositivo", - "cloud_courses": "Download cycles", + "cloud_courses": "Ciclos descargados", "forget_learned_modes": "Olvidar los modos recordados", "debug_write": "Depuración: escribir en un recurso" } @@ -94,18 +94,18 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "Ciclos descargados", + "description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.", "data": { - "download_course": "Download cycle course code" + "download_course": "Código de programa de Descarga de Programas" } } }, "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "Dos ciclos tienen el mismo nombre. Los nombres deben ser únicos.", + "cloud_course_unknown_course": "Ese programa no es uno que ofrezca este dispositivo. Elige uno de la lista." }, "abort": { "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." @@ -117,8 +117,8 @@ "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." + "title": "Ciclos descargados sin configurar para {device_name}", + "description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí." } }, "exceptions": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 88f0c116..a73afabb 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1406,7 +1406,7 @@ "title": "Opzioni LocalThings", "menu_options": { "settings": "Impostazioni dispositivo", - "cloud_courses": "Download cycles", + "cloud_courses": "Cicli scaricati", "forget_learned_modes": "Dimentica le modalità memorizzate", "debug_write": "Debug: scrivi su una risorsa" } @@ -1447,18 +1447,18 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "Cicli scaricati", + "description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.", "data": { - "download_course": "Download cycle course code" + "download_course": "Codice programma del ciclo Scaricato" } } }, "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "Due cicli hanno lo stesso nome. I nomi devono essere univoci.", + "cloud_course_unknown_course": "Quel programma non è tra quelli offerti da questo dispositivo. Selezionarne uno dall'elenco." }, "abort": { "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." @@ -1470,8 +1470,8 @@ "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." + "title": "Cicli scaricati non configurati per {device_name}", + "description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì." } }, "exceptions": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index d01a3702..d7f676f9 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1406,7 +1406,7 @@ "title": "LocalThings 옵션", "menu_options": { "settings": "기기 설정", - "cloud_courses": "Download cycles", + "cloud_courses": "다운로드 코스", "forget_learned_modes": "기억된 모드 지우기", "debug_write": "디버그: 리소스에 쓰기" } @@ -1447,18 +1447,18 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "다운로드 코스", + "description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.", "data": { - "download_course": "Download cycle course code" + "download_course": "다운로드 코스 코드" } } }, "error": { "empty_payload": "쓸 필드를 하나 이상 입력하세요.", "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "두 코스의 이름이 같습니다. 이름은 서로 달라야 합니다.", + "cloud_course_unknown_course": "이 기기가 제공하지 않는 코스입니다. 목록에서 선택하세요." }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." @@ -1470,8 +1470,8 @@ "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." + "title": "{device_name}의 다운로드 코스가 설정되지 않음", + "description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요." } }, "exceptions": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 04d28842..45e17950 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1406,7 +1406,7 @@ "title": "LocalThings-opties", "menu_options": { "settings": "Apparaatinstellingen", - "cloud_courses": "Download cycles", + "cloud_courses": "Gedownloade programma's", "forget_learned_modes": "Onthouden modi vergeten", "debug_write": "Foutopsporing: naar een resource schrijven" } @@ -1447,18 +1447,18 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", + "title": "Gedownloade programma's", + "description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.", "data": { - "download_course": "Download cycle course code" + "download_course": "Programmacode van \"Gedownload\"" } } }, "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.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", - "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." + "cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.", + "cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst." }, "abort": { "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." @@ -1470,8 +1470,8 @@ "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." + "title": "Gedownloade programma's niet ingesteld voor {device_name}", + "description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar." } }, "exceptions": { From d27bfc64057d05f0838af3f98dde927af4887dda Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:55:13 +0000 Subject: [PATCH 08/14] laundry: CloudExtraCourse_ means two different things; tell them apart Its bytes are not payload slots everywhere. On the DW5000C dishwasher all four (8E 8D 8F 02) are course codes in that device's own course list, three already translated -- Plastic, Pots and pans, Baby Care. There the token marks which ordinary courses came from the cloud; they select with a plain Course_ write and need no payload, which is consistent with it carrying no payload token at all. It also has a DownloadCourseList_ token the washers lack. On both washers the slots share zero overlap with the course list and a payload is required to select one. So the "this is not washer-only" claim was wrong, and gating on advertised_slots offered that dishwasher's owner a naming flow for programs that already work and are already named. The Repairs card was spared only because the payload gate added earlier happens to catch it. cloud_slots() subtracts the device's own course list, which separates the two readings without guessing at families: what remains is slots that cannot be selected any other way, which is what this module is for. Everything user-facing now gates on that -- the options menu entry, the naming flow, the Repairs count. The dishwasher gets nothing, both washers are unchanged. Found by reading the dishwasher fixture's options array while answering a question about it, which is also why diagnostics now reports advertised and cloud slots separately: the difference between them is the whole distinction. --- custom_components/localthings/cloudcourse.py | 49 ++++++++++++----- custom_components/localthings/config_flow.py | 6 ++- custom_components/localthings/coordinator.py | 10 ++-- custom_components/localthings/diagnostics.py | 4 ++ docs/investigations/download-cycle.md | 21 +++++--- tests/test_cloud_courses.py | 55 +++++++++++++------- 6 files changed, 101 insertions(+), 44 deletions(-) diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index 01afffa3..35fb0e2b 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -20,12 +20,12 @@ 2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one reason nothing here ever synthesizes one. -This is not washer-only, and nothing here assumes an appliance type: the -DW5000C dishwasher in ``tests/fixtures`` advertises four slots on the same -token. It also carries no payload token at all, so a device can name -programs whose payloads have never been observed -- ``advertised_slots`` -answers "which exist", the store answers "which are usable", and the two -are deliberately allowed to disagree. +``CloudExtraCourse_`` does not mean the same thing on every family, so +nothing keys off it directly -- see ``cloud_slots``, which is what the rest +of this module and its callers gate on. Even then, a device can advertise a +slot whose payload has never been observed, so ``cloud_slots`` answers +"which exist" while the store answers "which are usable"; the two are +deliberately allowed to disagree. What this module does and deliberately does not do -------------------------------------------------- @@ -133,9 +133,29 @@ def advertised_slots(rep) -> list[str]: return list(dict.fromkeys(hex_pairs(raw.upper()))) -def supports_cloud_courses(rep) -> bool: - """True for a device that advertises any downloaded-program slot.""" - return bool(advertised_slots(rep)) +def cloud_slots(rep, courses) -> list[str]: + """Advertised slots that are not already selectable courses. + + `CloudExtraCourse_` does not mean the same thing on every family. On the + washers its bytes are opaque payload slots sharing nothing with the + device's own course list, and selecting one needs the full payload. On + the DW5000C dishwasher all four of its bytes *are* course codes in that + device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and + one untranslated), so there it is tagging which of its ordinary courses + came from the cloud. Those are already selectable as plain `Course_` + writes and need nothing from this module. + + Subtracting the course list tells the two apart without having to guess + the family: what remains is slots that cannot be selected any other way, + which is exactly the set this module exists for. + """ + known = {c.upper() for c in courses or ()} + return [slot for slot in advertised_slots(rep) if slot not in known] + + +def supports_cloud_courses(rep, courses) -> bool: + """True for a device with downloaded programs it cannot otherwise run.""" + return bool(cloud_slots(rep, courses)) def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]: @@ -314,9 +334,10 @@ def _is_usable(record) -> bool: return bool(record["name"]) -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.""" +def undiscovered(rep: dict, record: dict, courses) -> list[str]: + """Cloud 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. Counts against cloud_slots, not every advertised byte: + a slot that is already a selectable course is nothing to set up.""" programs = record.get("slots") or {} - return [slot for slot in advertised_slots(rep) if not (programs.get(slot) or {}).get("name")] + return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")] diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 1f196a55..bcd6dfb1 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -840,7 +840,9 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con # 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()): + if coord is not None and cloudcourse.supports_cloud_courses( + coord.cloud_course_rep(), cycle_options(coord.canonical_resources(MAIN)) + ): menu.insert(1, "cloud_courses") return self.async_show_menu(step_id="init", menu_options=menu) @@ -935,7 +937,7 @@ async def async_step_cloud_courses( rep = coord.cloud_course_rep() record = store.snapshot() slots = record["slots"] - advertised = cloudcourse.advertised_slots(rep) + advertised = cloudcourse.cloud_slots(rep, cycle_options(coord.canonical_resources(MAIN))) # 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] diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index c2de6dab..151f4ebb 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -56,6 +56,7 @@ remote_control_enabled, remote_control_required_for_write, ) +from .registry.capabilities.laundry import cycle_options from .registry.discovery import BoundEntity from .registry.entities import ClimateDesc from .registry.identity import ( @@ -66,6 +67,7 @@ resolve_serial, ) from .registry.subdevices import ( + MAIN, Subdevice, canonical_view, discover_partitioned, @@ -548,8 +550,10 @@ def _refresh_cloud_course_issue(self) -> None: issue_id = f"cloud_courses_{self._entry.entry_id}" rep = self.cloud_course_rep() record = self._cloud.snapshot() - pending = cloudcourse.undiscovered(rep, record) - needs_course = bool(cloudcourse.advertised_slots(rep)) and not record["download_course"] + courses = cycle_options(self.canonical_resources(MAIN)) + pending = cloudcourse.undiscovered(rep, record, courses) + slots = cloudcourse.cloud_slots(rep, courses) + needs_course = bool(slots) and not record["download_course"] if record["slots"] and (pending or needs_course): ir.async_create_issue( self.hass, @@ -561,7 +565,7 @@ def _refresh_cloud_course_issue(self) -> None: translation_placeholders={ "device_name": self.device_info.get("name") or "This appliance", "pending": str(len(pending)), - "total": str(len(cloudcourse.advertised_slots(rep))), + "total": str(len(slots)), }, learn_more_url=DEVICE_SUPPORT_ISSUE_URL, ) diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 6141e35c..b61124b8 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -19,6 +19,7 @@ from . import cloudcourse from .const import DOMAIN from .coordinator import LocalThingsCoordinator +from .registry.capabilities.laundry import cycle_options from .registry.redact import redact_resources from .registry.subdevices import MAIN @@ -153,6 +154,9 @@ def _subdevice_diag(su) -> dict: # because its owner chose to download and share it. "cloud_courses": { "advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()), + "cloud_slots": cloudcourse.cloud_slots( + coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN)) + ), **cloud_courses, }, "integration_version": integration.version, diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index 99d0f814..9b8f5fc1 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -15,16 +15,25 @@ no cloud tokens at all, so this is a minority feature): | --- | --- | --- | --- | --- | | `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes | | `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes | -| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 | **0** | — | +| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — | | (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes | Three things follow immediately from that table: -- **It isn't washer-only.** The DW5000C is a `DA_DW_` dishwasher. -- **A device can advertise programs it has never loaded.** The DW5000C names - four slots and carries no `CloudCourse_`/`OneTimeCloudCourse_` token at - all. Nothing about it is learnable until its owner runs one, which is - exactly the situation the Repairs issue exists to explain. +- **`CloudExtraCourse_` does not mean the same thing on every family.** On + the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that + dishwasher's *own* course list, three already translated (Plastic, Pots and + pans, Baby Care). There it tags which ordinary courses came from the cloud; + they select with a plain `Course_` write and need no payload — consistent + with it carrying no payload token at all. It also has a + `DownloadCourseList_8F` token the washers lack. + On both washers the slots share **zero** overlap with the course list and a + payload is required. Subtracting the course list is what tells the two + apart (`cloudcourse.cloud_slots`), so the feature engages on the washers + and correctly does nothing on the dishwasher. +- **A device can advertise a slot it has never loaded.** True on the washers + too — nothing about a program is learnable until its owner runs it, which + is what the Repairs issue exists to explain. - **Both WW5000C units advertise the byte-identical slot list** (`0A5C286B2D0C55301A`, same nine slots in the same order) despite different firmware builds. Either the set is a factory/regional default rather than diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index a4cab657..e2b73305 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -78,7 +78,7 @@ def test_advertised_slots_preserve_device_order(self): ] def test_no_cloud_tokens_means_unsupported(self): - assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"])) is False + assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"]), ["1C"]) is False class TestRealDumps: @@ -122,23 +122,39 @@ def test_wa55_proposes_no_download_course(self): store.observe(rep) assert store.download_candidates() == [] - def test_a_dishwasher_advertises_programs_it_has_never_loaded(self): - """DW5000C (issues #113/#123): CloudExtraCourse_ names four slots - with no CloudCourse_/OneTimeCloudCourse_ token anywhere in the array. + def test_a_dishwasher_tags_its_own_courses_not_payload_slots(self): + """DW5000C (issues #113/#123). Its CloudExtraCourse_ names four + bytes, and all four are course codes in its *own* course list -- + 8E/8D/8F/02, three of them already translated (Plastic, Pots and + pans, Baby Care). There it marks which ordinary courses came from + the cloud; they select with a plain Course_ write and need nothing + from this module. It carries no payload token at all, consistent + with that. + + So none of this feature applies to it, and offering its owner a + naming flow for programs that already work would be nonsense. The + washers are the other shape: zero overlap with their course lists, + and a payload required to select one.""" + resources = _load_device("dishwasher_dw5000c_cloud") + rep = resources["/course/vs/0"] + courses = laundry.cycle_options(resources) - Two things at once -- the feature is not washer-only (DA_DW, not - DA_WM), and a device can advertise programs whose payloads have never - been observed. Nothing is learnable here, so nothing is offerable, - but the gap is still countable and still worth telling the user - about.""" - rep = _load_device("dishwasher_dw5000c_cloud")["/course/vs/0"] assert cloudcourse.advertised_slots(rep) == ["8E", "8D", "8F", "02"] - assert cloudcourse.supports_cloud_courses(rep) is True - - store = cloudcourse.CloudCourses() - assert store.observe(rep) is False - assert store.view() == {} - assert cloudcourse.undiscovered(rep, store.snapshot()) == ["8E", "8D", "8F", "02"] + assert set(cloudcourse.advertised_slots(rep)) <= set(courses) + assert cloudcourse.cloud_slots(rep, courses) == [] + assert cloudcourse.supports_cloud_courses(rep, courses) is False + assert cloudcourse.undiscovered(rep, cloudcourse.CloudCourses().snapshot(), courses) == [] + + def test_washer_slots_share_nothing_with_the_course_list(self): + """The distinguishing property, on both washers -- which is what + makes subtracting the course list a safe way to tell the two + meanings of CloudExtraCourse_ apart.""" + for name in ("washer_ww5000c_cloud", "washer_wa55a7700av"): + resources = _load_device(name) + rep = resources["/course/vs/0"] + courses = laundry.cycle_options(resources) + assert set(cloudcourse.advertised_slots(rep)).isdisjoint(courses), name + assert cloudcourse.supports_cloud_courses(rep, courses) is True, name def test_byte_three_is_not_part_of_a_programs_identity(self): """Two WW5000C units on different firmware (issue #342's _B06C and @@ -180,10 +196,11 @@ def test_undiscovered_counts_against_what_the_device_advertises(self): 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 + courses = laundry.cycle_options(_load_device("washer_ww5000c_cloud")) + assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 9 store.set_name("55", "Sports") - assert "55" not in cloudcourse.undiscovered(rep, store.snapshot()) - assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 8 + assert "55" not in cloudcourse.undiscovered(rep, store.snapshot(), courses) + assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 8 class TestStoreRules: From a4981f40a25d4d788a0d450fa548003982f4f602 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 09:40:13 +0000 Subject: [PATCH 09/14] laundry: guided setup for download cycles Naming downloaded programs from a list of hex slot ids was the weak part of this feature: it asked about programs in the abstract, long after the user had touched the appliance, and the per-slot fields rendered as raw keys because Home Assistant can't translate dynamic ones. Guided setup asks in the moment instead. It waits on a progress step while the user selects a program on the appliance, then asks for that one's name -- so the field is a single static key, and "which one is this?" is answered by the user having just turned the dial to it. The prompt also shows the appliance's own reported remaining time, which differs per program and is device-reported rather than decoded. Two things it has to get right: - It waits for a *transition*, not a state. After naming a program the appliance is still sitting on it, so a loop keyed on "a known slot is loaded" would re-offer the same one forever. Each round baselines on whatever is loaded when it starts. - Re-selecting an already-named program is not an error -- it is how someone checks their work -- so it gets the existing name pre-filled and the counter deliberately does not move, rather than a rejection. Names persist as they are entered rather than batching to the end of the flow, which makes closing the dialog a clean "save and exit" with nothing pending to lose, and makes the flow resumable: reopening picks up from the store. async_remove cancels an in-flight round, so walking away actually stops the probing instead of holding the session lock every few seconds until the timeout. /course/vs/0 is cold-tier, so passively a selection can take a whole poll interval to appear. async_probe_cloud_courses live-reads it through the normal apply path, keeping learning and persistence in one place. The bulk form stays, under its own step, as the way to rename things later -- which guided setup is bad at. New strings ship in English in every catalog and need translating. --- custom_components/localthings/cloudcourse.py | 16 ++ custom_components/localthings/config_flow.py | 174 ++++++++++++++- custom_components/localthings/coordinator.py | 23 ++ .../localthings/translations/cs.json | 31 ++- .../localthings/translations/de.json | 31 ++- .../localthings/translations/en.json | 31 ++- .../localthings/translations/es.json | 31 ++- .../localthings/translations/it.json | 31 ++- .../localthings/translations/ko.json | 31 ++- .../localthings/translations/nl.json | 31 ++- tests/test_cloud_courses_flow.py | 210 +++++++++++++++++- 11 files changed, 622 insertions(+), 18 deletions(-) diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index 35fb0e2b..bf916476 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -158,6 +158,22 @@ def supports_cloud_courses(rep, courses) -> bool: return bool(cloud_slots(rep, courses)) +def loaded_slot(rep) -> str | None: + """The slot whose payload the appliance currently holds -- the one-time + override when one is set, else the saved default. + + Deliberately not gated on the course being Download: the guided setup + flow watches this before the Download course has been confirmed, and + during that walk a change here *is* the signal that the user selected a + different program. A stale token can't produce a false positive because + the flow waits for a change from its own baseline, not for a value. + """ + options = rep.get("x.com.samsung.da.options") + return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of( + option_value(options, DEFAULT_PREFIX) + ) + + 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 diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index bcd6dfb1..5044b61a 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib import datetime import errno @@ -77,6 +78,14 @@ ) ) +# Guided download-cycle setup: how long a round waits for the user to +# select a program, and how often it live-reads /course/vs/0 while doing +# so. The read takes the session lock, so the interval is a few seconds +# rather than sub-second -- fast enough to feel immediate to someone +# standing at the appliance, slow enough not to starve polling. +_CLOUD_WAIT_TIMEOUT_S = 180.0 +_CLOUD_PROBE_INTERVAL_S = 3.0 + _LOGGER = logging.getLogger(__name__) _SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com" @@ -830,6 +839,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): def __init__(self) -> None: self._debug_href: str = "" self._debug_result: tuple[int, dict] | None = None + # Guided download-cycle setup. `_cloud_task` is created once per + # round and reused across re-entries (Home Assistant re-enters a + # progress step while its spinner is up). + self._cloud_task: asyncio.Task[str | None] | None = None + self._cloud_slot: str | None = None + self._cloud_baseline: str | None = None def _coordinator(self): return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id) @@ -912,6 +927,163 @@ async def async_step_forget_learned_modes( async def async_step_cloud_courses( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Entry point for download-cycle setup (issue #342). + + Guided setup is offered first because it is the only version of this + that a first-time user can complete confidently: it asks about a + program in the moment they select it, rather than about a list of hex + ids some time later. The bulk form stays for renaming afterwards, + which guided setup is bad at. + """ + return self.async_show_menu( + step_id="cloud_courses", + menu_options=["cloud_guided", "cloud_manual"], + ) + + @callback + def async_remove(self) -> None: + """Stop probing when the flow goes away. + + Closing the dialog is the documented way to leave guided setup, so it + has to actually stop: an abandoned round would otherwise go on + live-reading /course/vs/0 every few seconds until its timeout, taking + the session lock each time, for a user who has walked away. + """ + if self._cloud_task is not None and not self._cloud_task.done(): + self._cloud_task.cancel() + self._cloud_task = None + + async def async_step_cloud_guided( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Start (or restart) a guided discovery round.""" + coord = self._coordinator() + if coord is None: + return self.async_abort(reason="not_loaded") + self._cloud_task = None + self._cloud_slot = None + # Baseline: whatever is loaded right now. The round completes when + # the appliance moves off it, so the program the user has *already* + # selected can't immediately re-trigger and loop the flow. + self._cloud_baseline = cloudcourse.loaded_slot(coord.cloud_course_rep()) + return await self.async_step_cloud_wait() + + async def async_step_cloud_wait( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Wait for the user to select a different downloaded program. + + The task is created once and reused across re-entries -- Home + Assistant polls this step while the spinner is up, and building a + fresh task each time would restart the wait forever. + """ + coord = self._coordinator() + if coord is None: + return self.async_abort(reason="not_loaded") + + if self._cloud_task is None: + self._cloud_task = self.hass.async_create_task( + self._await_cloud_selection(coord), eager_start=False + ) + if not self._cloud_task.done(): + return self.async_show_progress( + step_id="cloud_wait", + progress_action="cloud_wait", + progress_task=self._cloud_task, + description_placeholders=self._cloud_progress_placeholders(coord), + ) + + self._cloud_slot = self._cloud_task.result() + self._cloud_task = None + if self._cloud_slot is None: + return self.async_show_progress_done(next_step_id="cloud_timeout") + return self.async_show_progress_done(next_step_id="cloud_name") + + async def _await_cloud_selection(self, coord) -> str | None: + """Poll until the loaded program changes; None on timeout.""" + deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S + while time.monotonic() < deadline: + slot = await coord.async_probe_cloud_courses() + if slot is not None and slot != self._cloud_baseline: + return slot + await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S) + return None + + def _cloud_progress_placeholders(self, coord) -> dict[str, str]: + rep = coord.cloud_course_rep() + courses = cycle_options(coord.canonical_resources(MAIN)) + record = coord.cloud_courses.snapshot() + slots = cloudcourse.cloud_slots(rep, courses) + named = sum(1 for s in slots if (record["slots"].get(s) or {}).get("name")) + return {"named": str(named), "total": str(len(slots))} + + async def async_step_cloud_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Name the program the user just selected. + + Persists immediately rather than batching to the end of the flow, so + closing the dialog at any point is a clean "save and exit" -- there is + no pending work to lose, and reopening resumes from the store. + """ + coord = self._coordinator() + if coord is None or self._cloud_slot is None: + return self.async_abort(reason="not_loaded") + slot = self._cloud_slot + existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "") + + if user_input is not None: + name = str(user_input.get("name", "")).strip() + errors = self._apply_cloud_course_names(coord, [slot], {f"name_{slot}": name}) + if errors: + return self._cloud_name_form(coord, slot, existing, errors=errors) + # Straight back to waiting: the appliance is still sitting on this + # program, and the next round baselines on it, so there is nothing + # to click through. + self._cloud_baseline = slot + return await self.async_step_cloud_wait() + + return self._cloud_name_form(coord, slot, existing) + + def _cloud_name_form( + self, coord, slot: str, existing: str, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """One text field, with the copy switched on whether this program is + already set up. Re-selecting one is not an error -- it is how someone + checks their work -- so it gets an edit form rather than a rejection, + and the counter deliberately does not move.""" + placeholders = self._cloud_progress_placeholders(coord) + placeholders["slot"] = slot + placeholders["remaining"] = ( + coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--" + ) + return self.async_show_form( + step_id="cloud_name", + data_schema=vol.Schema({vol.Optional("name", default=existing): _TEXT}), + errors=errors or {}, + description_placeholders=placeholders, + last_step=False, + ) + + async def async_step_cloud_timeout( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Nothing was selected in time. Offer another round rather than + dropping the user out of the flow -- and an explicit finish, for + anyone who doesn't think to close the dialog.""" + return self.async_show_menu( + step_id="cloud_timeout", + menu_options=["cloud_guided", "cloud_finish"], + ) + + async def async_step_cloud_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + return self.async_create_entry(data=dict(self.config_entry.options)) + + async def async_step_cloud_manual( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Name the cloud "Download" programs this appliance has (issue #342). @@ -1036,7 +1208,7 @@ def _cloud_courses_form( pending = [s for s in advertised if s not in slots] return self.async_show_form( - step_id="cloud_courses", + step_id="cloud_manual", data_schema=vol.Schema(fields), errors=errors or {}, description_placeholders={ diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 151f4ebb..259f81df 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -513,6 +513,29 @@ def cloud_course_rep(self) -> dict: """/course/vs/0's live rep -- what advertises the slot list.""" return self.resource(cloudcourse.COURSE_HREF) + async def async_probe_cloud_courses(self) -> str | None: + """Live-read /course/vs/0, apply it, and report which slot is loaded. + + /course/vs/0 is cold-tier, so passively a program selection can take + a whole poll interval to show up -- far too slow for the guided setup + flow, which is a person standing at the appliance waiting for Home + Assistant to notice. The read goes through the normal apply path, so + learning and persistence still happen in exactly one place. + + Returns None when the read fails; the caller is a retry loop and a + single missed read is not worth surfacing. + """ + try: + code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF) + except Exception: + # One missed probe; the caller is a retry loop. + self._log.debug("cloud-course probe failed", exc_info=True) + return None + if not _coap_accepted(code) or not rep: + return None + self._observe.apply(cloudcourse.COURSE_HREF, rep, source="poll") + return cloudcourse.loaded_slot(rep) + def apply_cloud_courses(self, names: dict[str, str], download_course: str | None) -> None: """The one mutation path for the cloud-program store (issue #342). diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index ad69fae2..6f2060b5 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1446,12 +1446,38 @@ "finish": "Dokončit" } }, - "cloud_courses": { + "cloud_manual": { "title": "Stažené cykly", "description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.", "data": { "download_course": "Kód programu „Stažený program“" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 2a5e023e..e81aff2f 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1446,12 +1446,38 @@ "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": { + "cloud_manual": { "title": "Download-Programme", "description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.", "data": { "download_course": "Programmcode des Download-Programms" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 8c26a135..c593801f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1446,12 +1446,38 @@ "finish": "Finish" } }, - "cloud_courses": { + "cloud_manual": { "title": "Download cycles", "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and 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\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "This device isn't connected yet. Try again once it has loaded." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 5ae59522..86567e97 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -93,12 +93,38 @@ "finish": "Finalizar" } }, - "cloud_courses": { + "cloud_manual": { "title": "Ciclos descargados", "description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.", "data": { "download_course": "Código de programa de Descarga de Programas" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -109,6 +135,9 @@ }, "abort": { "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index a73afabb..0884c2e0 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1446,12 +1446,38 @@ "finish": "Fine" } }, - "cloud_courses": { + "cloud_manual": { "title": "Cicli scaricati", "description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.", "data": { "download_course": "Codice programma del ciclo Scaricato" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index d7f676f9..91bea6bd 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1446,12 +1446,38 @@ "finish": "완료" } }, - "cloud_courses": { + "cloud_manual": { "title": "다운로드 코스", "description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.", "data": { "download_course": "다운로드 코스 코드" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 45e17950..214bb03b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1446,12 +1446,38 @@ "finish": "Voltooien" } }, - "cloud_courses": { + "cloud_manual": { "title": "Gedownloade programma's", "description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.", "data": { "download_course": "Programmacode van \"Gedownload\"" } + }, + "cloud_courses": { + "title": "Download cycles", + "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "menu_options": { + "cloud_guided": "Guided setup", + "cloud_manual": "Edit names" + } + }, + "cloud_wait": { + "title": "Download cycles" + }, + "cloud_name": { + "title": "Name this cycle", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "data": { + "name": "Name" + } + }, + "cloud_timeout": { + "title": "No cycle selected", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "error": { @@ -1462,6 +1488,9 @@ }, "abort": { "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." + }, + "progress": { + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." } }, "issues": { diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 440dc50f..52bad552 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -10,10 +10,12 @@ from __future__ import annotations import asyncio +import contextlib import json from typing import Any, cast import cbor2 +import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -26,6 +28,18 @@ from tests.conftest import _load_device from tests.test_subdevice_discovery import ENTRY_DATA + +@pytest.fixture(autouse=True) +def _fast_guided_waits(monkeypatch): + """Guided setup polls the appliance on a human timescale. Left at its + real values a single lingering round would hold the suite for its whole + timeout, since async_block_till_done waits on the task.""" + from custom_components.localthings import config_flow as cf + + monkeypatch.setattr(cf, "_CLOUD_PROBE_INTERVAL_S", 0.01) + monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.5) + + FIXTURE = "washer_ww5000c_cloud" COURSE = cloudcourse.COURSE_HREF @@ -251,8 +265,8 @@ async def test_naming_through_the_flow_persists(hass: HomeAssistant): coordinator = await _coordinator(hass, entry) handler = await _options_handler(hass, coordinator) - await handler.async_step_cloud_courses() - await handler.async_step_cloud_courses( + await handler.async_step_cloud_manual() + await handler.async_step_cloud_manual( {"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"} ) await _flush(hass) @@ -265,7 +279,7 @@ 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( + result = await handler.async_step_cloud_manual( {"name_55": "Sports", "name_6B": "sports", "download_course": "87"} ) assert result["errors"] == {"base": "cloud_course_name_duplicate"} @@ -275,11 +289,11 @@ async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant) 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 handler.async_step_cloud_manual({"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 handler.async_step_cloud_manual({"name_55": "", "download_course": "87"}) await _flush(hass) assert "cloud:55" not in _cycle_options(coordinator) @@ -288,7 +302,7 @@ async def test_without_a_confirmed_download_course_nothing_is_offered(hass: Home """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 handler.async_step_cloud_manual({"name_55": "Sports", "download_course": ""}) await _flush(hass) assert coordinator.cloud_courses.named() == {"55": "Sports"} @@ -298,9 +312,9 @@ async def test_without_a_confirmed_download_course_nothing_is_offered(hass: Home 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() + result = await handler.async_step_cloud_manual() - assert result["step_id"] == "cloud_courses" + assert result["step_id"] == "cloud_manual" assert result["description_placeholders"]["total"] == "9" # Two learned so far, seven still to walk through on the appliance. assert result["description_placeholders"]["found"] == "2" @@ -380,7 +394,7 @@ async def test_the_flow_rejects_a_download_course_the_device_does_not_offer( coordinator = await _coordinator(hass) handler = await _options_handler(hass, coordinator) - result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"}) + result = await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "FF"}) assert result["errors"] == {"base": "cloud_course_unknown_course"} assert coordinator.cloud_courses.snapshot()["download_course"] is None assert coordinator.cloud_courses.named() == {} @@ -397,5 +411,181 @@ async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAss await _flush(hass) handler = await _options_handler(hass, coordinator) - result = await handler.async_step_cloud_courses({"name_55": "MyCo", "download_course": "87"}) + result = await handler.async_step_cloud_manual({"name_55": "MyCo", "download_course": "87"}) assert result["errors"] == {"base": "cloud_course_name_duplicate"} + + +# --------------------------------------------------------------------------- +# Guided setup +# --------------------------------------------------------------------------- + + +def _stub_probe(coordinator, sequence): + """Drive async_probe_cloud_courses from a scripted list of loaded slots, + standing in for the user turning the dial. The last entry repeats.""" + seq = list(sequence) + + async def probe() -> str | None: + return seq.pop(0) if len(seq) > 1 else seq[0] + + # setattr, not assignment: the stub stands in for a bound method. + setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010 + + +async def _run_wait(handler): + """Re-enter the progress step the way Home Assistant does until its task + settles, then follow the completion through.""" + result = await handler.async_step_cloud_wait() + for _ in range(200): + if result["type"] != "progress": + return result + if handler._cloud_task is not None: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(asyncio.shield(handler._cloud_task), 5) + result = await handler.async_step_cloud_wait() + raise AssertionError("progress step never settled") + + +async def test_guided_waits_for_a_change_not_a_state(hass: HomeAssistant): + """The trap this design exists to avoid. After naming a program the + appliance is still sitting on it, so a loop that fires on "a known slot is + loaded" would re-offer the same one forever. Each round baselines on what + is loaded when it starts and completes only on a change.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + # The fixture is already loaded with 6B, so that is the baseline. + await handler.async_step_cloud_guided() + assert handler._cloud_baseline == "6B" + + # The appliance stays on 6B: no round completes. + _stub_probe(coordinator, ["6B"]) + handler._cloud_task = None + task = hass.async_create_task(handler._await_cloud_selection(coordinator)) + await asyncio.sleep(0) + assert not task.done() + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +async def test_guided_names_a_newly_selected_program(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + + _stub_probe(coordinator, ["55"]) + result = await _run_wait(handler) + assert result["step_id"] == "cloud_name" + + form = await handler.async_step_cloud_name() + assert form["description_placeholders"]["slot"] == "55" + # Persisted immediately -- closing the dialog now would lose nothing. + await handler.async_step_cloud_name({"name": "Sports"}) + await _flush(hass) + assert coordinator.cloud_courses.named() == {"55": "Sports"} + + +async def test_guided_rebaselines_so_the_named_program_cannot_refire( + hass: HomeAssistant, +): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + await handler.async_step_cloud_name({"name": "Sports"}) + await _flush(hass) + # The next round starts from the program just named, so the appliance + # sitting on it is not a new selection. + assert handler._cloud_baseline == "55" + + +async def test_reselecting_a_named_program_offers_an_edit_not_an_error( + hass: HomeAssistant, +): + """Re-picking one is how someone checks their work. It gets the existing + name pre-filled, and the counter deliberately does not move.""" + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"55": "Sports"}, "87") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + form = await handler.async_step_cloud_name() + assert form["errors"] == {} + assert form["data_schema"]({})["name"] == "Sports" + before = form["description_placeholders"]["named"] + await handler.async_step_cloud_name({"name": "Sports"}) + await _flush(hass) + after = handler._cloud_progress_placeholders(coordinator)["named"] + assert before == after == "1" + + +async def test_guided_times_out_into_a_retry_or_finish_menu(hass: HomeAssistant, monkeypatch): + from custom_components.localthings import config_flow as cf + + monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.0) + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["6B"]) + + result = await _run_wait(handler) + assert result["step_id"] == "cloud_timeout" + menu = await handler.async_step_cloud_timeout() + assert set(menu["menu_options"]) == {"cloud_guided", "cloud_finish"} + + +async def test_a_failed_probe_does_not_end_the_round(hass: HomeAssistant): + """A dropped read is one missed poll, not a reason to bail on someone + standing at the appliance.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, [None, None, "55"]) + + result = await _run_wait(handler) + assert result["step_id"] == "cloud_name" + assert handler._cloud_slot == "55" + + +async def test_the_entry_step_is_a_menu_offering_both_paths(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_cloud_courses() + assert set(result["menu_options"]) == {"cloud_guided", "cloud_manual"} + + +async def test_closing_the_dialog_stops_probing_the_appliance(hass: HomeAssistant): + """Closing the dialog is the documented way to leave guided setup, so it + has to actually stop -- an abandoned round would otherwise keep taking the + session lock every few seconds for a user who has walked away.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + + probes = 0 + + async def probe() -> str | None: + nonlocal probes + probes += 1 + return "6B" # never changes, so the round would run to timeout + + # setattr, not assignment: the stub stands in for a bound method. + setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010 + result = await handler.async_step_cloud_wait() + assert result["type"] == "progress" + task = handler._cloud_task + assert task is not None and not task.done() + + handler.async_remove() + with contextlib.suppress(asyncio.CancelledError): + await task + assert task.cancelled() + settled = probes + await asyncio.sleep(0.05) + assert probes == settled From 78a545341b4b19ad9ea2eb96a25ac6c3f6294afc Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 09:57:21 +0000 Subject: [PATCH 10/14] laundry: show the names assigned so far during guided setup A nine-program walk is hard to keep your place in. The counter alone doesn't say what you've already done, and the programs still to do can't be listed -- they're unnamed by definition, which is the whole premise. So "named so far" is the only orientation available, and it now appears on all three guided screens. It doubles as duplicate avoidance on the naming form: a repeated name is rejected, so seeing the others while typing beats being bounced afterwards. Listed in the appliance's own advertised order rather than the order they were named -- a stable order either way, and not numbered: whether it matches the dial is plausible but unverified, and implying it would be worse than saying nothing. --- custom_components/localthings/config_flow.py | 21 ++++++++++-- .../localthings/translations/cs.json | 6 ++-- .../localthings/translations/de.json | 6 ++-- .../localthings/translations/en.json | 6 ++-- .../localthings/translations/es.json | 6 ++-- .../localthings/translations/it.json | 6 ++-- .../localthings/translations/ko.json | 6 ++-- .../localthings/translations/nl.json | 6 ++-- tests/test_cloud_courses_flow.py | 33 +++++++++++++++++++ 9 files changed, 73 insertions(+), 23 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 5044b61a..7afd95af 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -1011,12 +1011,29 @@ async def _await_cloud_selection(self, coord) -> str | None: return None def _cloud_progress_placeholders(self, coord) -> dict[str, str]: + """Counts plus the names assigned so far. + + Listing them is what makes a nine-program walk followable -- it is + the only orientation available, since the programs still to do are + unnamed by definition. Shown while naming too, where it doubles as + duplicate avoidance: the form rejects a repeated name, so seeing the + others first beats being bounced. + + In the appliance's own advertised order, which is at least a stable + order, without numbering them -- whether that order matches the dial + is plausible but unverified, and implying it would be worse than + saying nothing. + """ rep = coord.cloud_course_rep() courses = cycle_options(coord.canonical_resources(MAIN)) record = coord.cloud_courses.snapshot() slots = cloudcourse.cloud_slots(rep, courses) - named = sum(1 for s in slots if (record["slots"].get(s) or {}).get("name")) - return {"named": str(named), "total": str(len(slots))} + names = [n for s in slots if (n := (record["slots"].get(s) or {}).get("name"))] + return { + "named": str(len(names)), + "total": str(len(slots)), + "named_list": ", ".join(names) if names else "none yet", + } async def async_step_cloud_name( self, user_input: dict[str, Any] | None = None diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 6f2060b5..ba42b634 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index e81aff2f..9f1b2f7c 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c593801f..19c4a91e 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "This device isn't connected yet. Try again once it has loaded." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 86567e97..f3ec02d4 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -113,14 +113,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -137,7 +137,7 @@ "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 0884c2e0..b34ec07e 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 91bea6bd..30f234d9 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 214bb03b..70713639 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1466,14 +1466,14 @@ }, "cloud_name": { "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.", + "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { "name": "Name" } }, "cloud_timeout": { "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.", + "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", "menu_options": { "cloud_guided": "Wait again", "cloud_finish": "Finish" @@ -1490,7 +1490,7 @@ "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go." + "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." } }, "issues": { diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 52bad552..4ee1629a 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -589,3 +589,36 @@ async def probe() -> str | None: settled = probes await asyncio.sleep(0.05) assert probes == settled + + +async def test_guided_lists_the_names_assigned_so_far(hass: HomeAssistant): + """The only orientation available on a long walk -- the programs still to + do are unnamed by definition, so 'done so far' is all there is to show. + It doubles as duplicate avoidance, since the form rejects a repeat.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + assert handler._cloud_progress_placeholders(coordinator)["named_list"] == "none yet" + + coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87") + await _flush(hass) + placeholders = handler._cloud_progress_placeholders(coordinator) + assert placeholders["named"] == "2" + assert placeholders["total"] == "9" + # The appliance's own advertised order, not naming order: 6B precedes 55 + # in CloudExtraCourse_0A5C286B2D0C55301A. + assert placeholders["named_list"] == "Jeans, Sports" + + +async def test_the_name_form_shows_the_other_names_while_typing(hass: HomeAssistant): + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"6B": "Jeans"}, "87") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + form = await handler.async_step_cloud_name() + assert form["description_placeholders"]["named_list"] == "Jeans" From 0fbac7f14d1949d10d350c806541276138ab3411 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 10:08:12 +0000 Subject: [PATCH 11/14] laundry: guided setup left download cycles unselectable, and cleared the course A program is only offerable once it has both a name and the Download course code that goes in the Course_ token. Guided setup collected names and never asked about the course, so a user could walk all nine programs, watch every name save, and end up with nothing in the cycle list. Worse, it actively cleared the course. _apply_cloud_course_names read download_course out of the submitted form; the guided name form has no such field, so it passed None and apply_cloud_courses stored that. Naming a program therefore removed every previously-named program from the list. Traced on the fixture: 87 -> name one -> None -> nothing offerable. Two changes. apply_cloud_courses now defaults download_course to "leave it alone" rather than None, so silence can't be mistaken for a clear, and the guided path forwards the field only when its form actually carried it. And the first guided name form now asks for the course, prefilled from what was just observed, dropping the field once confirmed. Asking there rather than up front is deliberate: it is the first moment there is evidence to prefill, since the user has just loaded a program and the course showing alongside it is the Download one. That makes the walk stand on its own, which is the whole point of offering it as the primary path. The bulk form's course dropdown now shares the guided one's builder. Translations for the guided-setup strings are in for cs/de/es/it/ko/nl, matching the vocabulary the earlier pass established. The new field on the name form reuses each locale's existing label from the bulk form rather than adding an untranslated string. --- custom_components/localthings/config_flow.py | 81 ++++++++++++++----- custom_components/localthings/coordinator.py | 17 +++- .../localthings/translations/cs.json | 27 ++++--- .../localthings/translations/de.json | 27 ++++--- .../localthings/translations/en.json | 3 +- .../localthings/translations/es.json | 27 ++++--- .../localthings/translations/it.json | 27 ++++--- .../localthings/translations/ko.json | 27 ++++--- .../localthings/translations/nl.json | 27 ++++--- tests/test_cloud_courses_flow.py | 59 ++++++++++++++ 10 files changed, 219 insertions(+), 103 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 7afd95af..f7fb65bf 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -1051,8 +1051,13 @@ async def async_step_cloud_name( existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "") if user_input is not None: - name = str(user_input.get("name", "")).strip() - errors = self._apply_cloud_course_names(coord, [slot], {f"name_{slot}": name}) + # Rebuilt into the shared validator's shape. `download_course` + # is forwarded only when this form actually carried it, so its + # absence still means "not asked about" rather than "clear it". + payload: dict[str, Any] = {f"name_{slot}": str(user_input.get("name", "")).strip()} + if "download_course" in user_input: + payload["download_course"] = user_input["download_course"] + errors = self._apply_cloud_course_names(coord, [slot], payload) if errors: return self._cloud_name_form(coord, slot, existing, errors=errors) # Straight back to waiting: the appliance is still sitting on this @@ -1069,20 +1074,60 @@ def _cloud_name_form( """One text field, with the copy switched on whether this program is already set up. Re-selecting one is not an error -- it is how someone checks their work -- so it gets an edit form rather than a rejection, - and the counter deliberately does not move.""" + and the counter deliberately does not move. + + The Download course joins the form the first time round, and only + until it is confirmed. Guided setup would otherwise finish having + collected names but no course, and a program needs both before it can + be offered -- so the whole walk would produce nothing selectable. It + is asked here rather than up front because this is the first moment + there is evidence to prefill: the user has just loaded a program, so + the course showing alongside it is the Download one. + """ placeholders = self._cloud_progress_placeholders(coord) placeholders["slot"] = slot placeholders["remaining"] = ( coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--" ) + fields: dict[Any, Any] = {vol.Optional("name", default=existing): _TEXT} + if not coord.cloud_courses.snapshot()["download_course"]: + fields[ + vol.Optional("download_course", description={"suggested_value": self._cloud_course}) + ] = self._cloud_course_selector(coord) return self.async_show_form( step_id="cloud_name", - data_schema=vol.Schema({vol.Optional("name", default=existing): _TEXT}), + data_schema=vol.Schema(fields), errors=errors or {}, description_placeholders=placeholders, last_step=False, ) + @property + def _cloud_course(self) -> str | None: + coord = self._coordinator() + if coord is None: + return None + candidates = coord.cloud_courses.download_candidates() + return candidates[0] if candidates else None + + def _cloud_course_selector(self, coord): + """The appliance's own course codes, observed candidates first. + + custom_value stays off deliberately: whatever lands here becomes the + Course_ token of a real write, and a typed-in code the appliance + doesn't offer would start something nobody chose. + """ + available = cycle_options(coord.canonical_resources(MAIN)) + candidates = [c for c in coord.cloud_courses.download_candidates() if c in available] + ordered = candidates + [c for c in available if c not in candidates] + return SelectSelector( + SelectSelectorConfig( + options=ordered, + custom_value=False, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + async def async_step_cloud_timeout( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -1167,6 +1212,15 @@ def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: return {"base": "cloud_course_name_duplicate"} taken.add(name.casefold()) + # Absent means "this form didn't ask" -- the guided name form drops + # the field once the course is confirmed -- which must leave the + # stored value alone rather than clearing it. A program is only + # offerable when both a name and the course are set, so clearing it + # here would make naming things remove them from the cycle list. + if "download_course" not in user_input: + coord.apply_cloud_courses(names) + return {} + # Belt and braces over the selector's own custom_value=False: this # value becomes the Course_ token of a real write, so it is checked # against the appliance's own course list here too, where the store @@ -1203,24 +1257,9 @@ def _cloud_courses_form( _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. custom_value stays - # off deliberately: whatever lands here becomes the Course_ token of a - # real write, and a typed-in code the appliance doesn't offer would - # start something nobody chose. - 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) + suggested = record["download_course"] or self._cloud_course fields[vol.Optional("download_course", description={"suggested_value": suggested})] = ( - SelectSelector( - SelectSelectorConfig( - options=ordered, - custom_value=False, - mode=SelectSelectorMode.DROPDOWN, - ) - ) + self._cloud_course_selector(coord) ) pending = [s for s in advertised if s not in slots] diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 259f81df..ac3c3c6f 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -10,7 +10,7 @@ import time import zlib from datetime import timedelta -from typing import Any +from typing import Any, cast import cbor2 from homeassistant.config_entries import ConfigEntry @@ -75,6 +75,10 @@ normalize_seed_batch, ) +# Sentinel for apply_cloud_courses: "leave this field as it is", +# distinct from None which means "clear it". +_KEEP = object() + _LOGGER = logging.getLogger(__name__) _SEED_PATH = ["device", "0"] @@ -536,17 +540,24 @@ async def async_probe_cloud_courses(self) -> str | None: self._observe.apply(cloudcourse.COURSE_HREF, rep, source="poll") return cloudcourse.loaded_slot(rep) - def apply_cloud_courses(self, names: dict[str, str], download_course: str | None) -> None: + def apply_cloud_courses(self, names: dict[str, str], download_course: object = _KEEP) -> None: """The one mutation path for the cloud-program store (issue #342). Takes the whole submission at once so a nine-program naming pass is one config-entry write rather than nine, and so persistence, the canonical-view invalidation and the Repairs refresh can't be done for one half of a change and skipped for the other. + + `download_course` defaults to "leave it alone" rather than None. + Guided setup submits one name at a time and says nothing about the + course; with None as the default that silently cleared a course the + user had already confirmed, and since a program is only offerable + once both are set, naming things made them disappear. """ for slot, name in names.items(): self._cloud.set_name(slot, name) - self._cloud.set_download_course(download_course) + if download_course is not _KEEP: + self._cloud.set_download_course(cast("str | None", download_course)) self._persist_cloud_courses() @callback diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index ba42b634..00b9d405 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1454,29 +1454,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "Stažené cykly", + "description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "Průvodce nastavením", + "cloud_manual": "Upravit názvy" } }, "cloud_wait": { - "title": "Download cycles" + "title": "Stažené cykly" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "Pojmenujte tento cyklus", + "description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.", "data": { - "name": "Name" + "name": "Název", + "download_course": "Kód programu „Stažený program“" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "Nebyl vybrán žádný cyklus", + "description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "Počkat znovu", + "cloud_finish": "Dokončit" } } }, @@ -1490,7 +1491,7 @@ "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně." } }, "issues": { diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 9f1b2f7c..4c493edf 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1454,29 +1454,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "Download-Programme", + "description": "Die geführte Einrichtung begleitet Sie am Gerät: Wählen Sie am Gerät ein heruntergeladenes Programm aus und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "Geführte Einrichtung", + "cloud_manual": "Namen bearbeiten" } }, "cloud_wait": { - "title": "Download cycles" + "title": "Download-Programme" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "Dieses Programm benennen", + "description": "Das Gerät ist zu einem heruntergeladenen Programm gewechselt (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann am Gerät das nächste aus. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.", "data": { - "name": "Name" + "name": "Name", + "download_course": "Programmcode des Download-Programms" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "Kein Programm ausgewählt", + "description": "Am Gerät wurde nichts ausgewählt. Stellen Sie sicher, dass es auf das Download-Programm eingestellt ist, und gehen Sie dann die heruntergeladenen Programme durch.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "Erneut warten", + "cloud_finish": "Fertig" } } }, @@ -1490,7 +1491,7 @@ "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "Wählen Sie jetzt ein heruntergeladenes Programm am Gerät aus.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert." } }, "issues": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 19c4a91e..0602fe5a 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1468,7 +1468,8 @@ "title": "Name this cycle", "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", "data": { - "name": "Name" + "name": "Name", + "download_course": "Download cycle course code" } }, "cloud_timeout": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index f3ec02d4..81c3be40 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -101,29 +101,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "Ciclos descargados", + "description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "Configuración guiada", + "cloud_manual": "Editar nombres" } }, "cloud_wait": { - "title": "Download cycles" + "title": "Ciclos descargados" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "Nombra este ciclo", + "description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.", "data": { - "name": "Name" + "name": "Nombre", + "download_course": "Código de programa de Descarga de Programas" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "No se seleccionó ningún ciclo", + "description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "Esperar de nuevo", + "cloud_finish": "Finalizar" } } }, @@ -137,7 +138,7 @@ "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha." } }, "issues": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index b34ec07e..52378be9 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1454,29 +1454,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "Cicli scaricati", + "description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "Configurazione guidata", + "cloud_manual": "Modifica nomi" } }, "cloud_wait": { - "title": "Download cycles" + "title": "Cicli scaricati" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "Assegna un nome a questo ciclo", + "description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.", "data": { - "name": "Name" + "name": "Nome", + "download_course": "Codice programma del ciclo Scaricato" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "Nessun ciclo selezionato", + "description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "Attendi di nuovo", + "cloud_finish": "Fine" } } }, @@ -1490,7 +1491,7 @@ "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano." } }, "issues": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 30f234d9..e04d70e0 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1454,29 +1454,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "다운로드 코스", + "description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "안내 설정", + "cloud_manual": "이름 편집" } }, "cloud_wait": { - "title": "Download cycles" + "title": "다운로드 코스" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "이 코스 이름 지정", + "description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.", "data": { - "name": "Name" + "name": "이름", + "download_course": "다운로드 코스 코드" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "선택된 코스 없음", + "description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "다시 대기", + "cloud_finish": "완료" } } }, @@ -1490,7 +1491,7 @@ "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다." } }, "issues": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 70713639..d9ea0552 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1454,29 +1454,30 @@ } }, "cloud_courses": { - "title": "Download cycles", - "description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.", + "title": "Gedownloade programma's", + "description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.", "menu_options": { - "cloud_guided": "Guided setup", - "cloud_manual": "Edit names" + "cloud_guided": "Begeleide installatie", + "cloud_manual": "Namen bewerken" } }, "cloud_wait": { - "title": "Download cycles" + "title": "Gedownloade programma's" }, "cloud_name": { - "title": "Name this cycle", - "description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.", + "title": "Geef dit programma een naam", + "description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.", "data": { - "name": "Name" + "name": "Naam", + "download_course": "Programmacode van \"Gedownload\"" } }, "cloud_timeout": { - "title": "No cycle selected", - "description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.", + "title": "Geen programma geselecteerd", + "description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.", "menu_options": { - "cloud_guided": "Wait again", - "cloud_finish": "Finish" + "cloud_guided": "Opnieuw wachten", + "cloud_finish": "Voltooien" } } }, @@ -1490,7 +1491,7 @@ "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." }, "progress": { - "cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go." + "cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen." } }, "issues": { diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 4ee1629a..18e71c9d 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -622,3 +622,62 @@ async def test_the_name_form_shows_the_other_names_while_typing(hass: HomeAssist form = await handler.async_step_cloud_name() assert form["description_placeholders"]["named_list"] == "Jeans" + + +async def test_guided_naming_never_clears_a_confirmed_download_course(hass: HomeAssistant): + """A program needs both a name and the Download course before it can be + offered. The guided name form says nothing about the course, and passing + that silence through as None wiped a confirmed one -- so naming a program + removed every already-named program from the cycle list.""" + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"6B": "Jeans"}, "87") + await _flush(hass) + assert "cloud:6B" in _cycle_options(coordinator) + + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + await handler.async_step_cloud_name({"name": "Sports"}) + await _flush(hass) + + assert coordinator.cloud_courses.snapshot()["download_course"] == "87" + assert {"cloud:55", "cloud:6B"} <= set(_cycle_options(coordinator)) + + +async def test_guided_setup_alone_produces_a_selectable_cycle(hass: HomeAssistant): + """The walk has to stand on its own: someone who only ever uses guided + setup must end up with something in the cycle list. So the first name + form also asks for the Download course, prefilled from what was just + observed, and drops the field once it is confirmed.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + first = await handler.async_step_cloud_name() + assert "download_course" in str(first["data_schema"].schema) + # Prefilled from the observation the fixture already carries. + assert handler._cloud_course == "87" + + await handler.async_step_cloud_name({"name": "Sports", "download_course": "87"}) + await _flush(hass) + assert "cloud:55" in _cycle_options(coordinator) + + # Confirmed now, so the next program is asked for its name only. + handler._cloud_slot = "6B" + second = await handler.async_step_cloud_name() + assert "download_course" not in str(second["data_schema"].schema) + + +async def test_guided_rejects_a_download_course_the_device_does_not_offer(hass: HomeAssistant): + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + result = await handler.async_step_cloud_name({"name": "Sports", "download_course": "FF"}) + assert result["errors"] == {"base": "cloud_course_unknown_course"} + assert coordinator.cloud_courses.snapshot()["download_course"] is None From bb20eea19163a9ce6f01ad1221438c89e8b97695 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 10:16:00 +0000 Subject: [PATCH 12/14] laundry: a payload sitting there is not evidence of when it got there The Download-course candidate came from "Course_ read while a non-sentinel one-time payload is loaded". On the first rep after any restart that is indistinguishable from a payload left over from a previous run, so an appliance holding cloud payloads while sitting on an ordinary course proposed that ordinary course as the Download one. Accepting the prefill would then make selecting a downloaded program start, say, a cotton wash. Only a transition actually watched counts now. "Never observed" is a distinct state from "observed, nothing loaded" -- absent-then-loaded is a genuine selection and still counts -- so a restored store deliberately re-enters the unobserved state, since a restart cannot tell the two apart. Payloads are still learned from that first rep either way; which programs exist is device fact regardless of when they were loaded. It is only the inference about which course means Download that needs the timing. Both corpus dumps taken off the Download course show the appliance clearing its one-time token to the FFFF sentinel, so this may never fire on these boards. That is a reason to expect them to behave, not to depend on it. --- custom_components/localthings/cloudcourse.py | 24 +++++++++++-- docs/investigations/download-cycle.md | 22 ++++++++---- tests/test_cloud_courses.py | 38 +++++++++++++++++++- tests/test_cloud_courses_flow.py | 22 +++++++++++- 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index bf916476..70fdaacb 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -83,6 +83,11 @@ # its own CloudExtraCourse_ advertises. _SENTINEL_PREFIX = "FFFF" +# Distinct from None, which is a real observation that carried no payload. +# Only "never observed" suppresses a candidate; absent-then-loaded is a +# genuine transition and should count. +_UNOBSERVED = object() + # Byte offset within a blob that carries its slot id. _SLOT_BYTE = 2 _MIN_BLOB_BYTES = 4 @@ -226,8 +231,8 @@ def __init__(self, stored_record=None) -> None: # user confirmation (see download_candidates). self._candidates: dict[str, int] = {} # Last one-time payload seen, so a load can be told from a poll that - # merely re-reports one. None means "nothing observed yet". - self._last_oneshot: str | None = None + # merely re-reports one. _UNOBSERVED until the first rep arrives. + self._last_oneshot: object = _UNOBSERVED # -- learning --------------------------------------------------------- @@ -284,7 +289,20 @@ def observe(self, rep: dict) -> bool: changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()} course = option_value(options, COURSE_PREFIX) - if course and is_loaded(oneshot) and oneshot != self._last_oneshot: + # Only a transition we actually watched happen counts. On the + # first observation there is nothing to compare against, so a + # payload sitting there is equally consistent with "just loaded" + # and "left over from last week" -- and on a board that doesn't + # clear the token when leaving Download, believing the former + # proposes whatever ordinary course the appliance happens to be + # on. Accepting that prefill would start a real wash cycle. + # (The two dumps in the corpus taken off the Download course both + # show the appliance clearing it to the FFFF sentinel, so this + # may never fire in practice -- which is not a reason to rely on + # it.) Restores don't persist _last_oneshot, so every restart + # re-enters this first-observation state deliberately. + first_ever = self._last_oneshot is _UNOBSERVED + if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot: self._candidates[course] = self._candidates.get(course, 0) + 1 self._last_oneshot = oneshot return changed diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index 9b8f5fc1..10995ed4 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -111,12 +111,22 @@ 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 observation signal is "whatever `Course_` reads at the moment a +non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that +was actually watched, not a state. Tokens in this array are replaced by +prefix and never evicted, so a payload merely sitting there says nothing +about when it got there; on the first rep after a restart it is equally +consistent with "just loaded" and "left over from last week". Believing it +would propose whatever ordinary course the appliance happens to be sitting +on, and accepting that prefill starts a real wash cycle. Even a genuine +transition is only ever a *candidate*, confirmed by the user before use. + +Both dumps in the corpus taken while off the Download course +(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`) +show the appliance clearing its one-time token to the `FFFF` sentinel, so +the saved default persists but the one-shot does not. That makes the stale +case unlikely on these boards — which is a reason to expect it to behave, +not a reason to depend on it. ## The dead end: bytes 5/7/9 do not decode portably diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index e2b73305..0fa71fad 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -97,13 +97,29 @@ def test_reporter_dump_advertises_nine_and_learns_both_loaded_blobs(self): assert store.view() == {} def test_reporter_dump_proposes_its_download_course(self): + """Only once a load has actually been watched. A single observation + can't tell "just loaded" from "left over", so the dump alone proposes + nothing -- see test_a_single_observation_proposes_nothing.""" rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] store = cloudcourse.CloudCourses() - store.observe(rep) + store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) + store.observe(rep) # one-time token moves to Jeans while on Course_87 assert store.download_candidates() == ["87"] # A candidate is never used until confirmed. assert store.snapshot()["download_course"] is None + def test_a_single_observation_proposes_nothing(self): + """The case a user hits after any restart: the appliance has cloud + payloads from previous runs and is sitting on an ordinary course. If + the board doesn't clear its one-time token, believing the first + observation would propose that ordinary course as the Download one -- + and accepting the prefill would start a real wash cycle.""" + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"])) + assert store.download_candidates() == [] + # The payload is still learned -- that part is device fact. + assert store.snapshot()["slots"]["55"]["blob"] == SPORTS + 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"] @@ -234,6 +250,7 @@ def test_candidates_rank_by_program_loads_not_by_dwell_time(self): store = cloudcourse.CloudCourses() loaded = _rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]) stale = _rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]) + store.observe(_rep(["CloudExtraCourse_55", "Course_87"])) # baseline store.observe(loaded) for _ in range(200): # ~100 minutes sitting on a cotton cycle store.observe(stale) @@ -241,6 +258,7 @@ def test_candidates_rank_by_program_loads_not_by_dwell_time(self): def test_a_second_distinct_load_is_counted(self): store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_556B", "Course_87"])) # baseline store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{JEANS}"])) assert store.download_candidates() == ["87"] @@ -401,3 +419,21 @@ def test_cloud_prefixes_do_not_poison_the_course_lookup(self): options = [f"CloudCourse_{SPORTS}", f"OneTimeCloudCourse_{JEANS}", "Course_1C"] assert laundry.option_value(options, "Course") == "1C" assert cloudcourse.option_value(options, "Course") == "1C" + + def test_a_payload_appearing_while_watching_is_a_real_transition(self): + """Absent-then-loaded is the user selecting a program, and must count + -- only "never observed at all" suppresses a candidate.""" + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_55", "Course_87"])) + store.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) + assert store.download_candidates() == ["87"] + + def test_a_stale_payload_across_a_restart_proposes_nothing(self): + """A restored store starts unobserved again on purpose: the first rep + after a restart is indistinguishable from a stale one, whatever was + persisted.""" + seeded = cloudcourse.CloudCourses() + seeded.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) + restored = cloudcourse.CloudCourses(seeded.snapshot()) + restored.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"])) + assert restored.download_candidates() == [] diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 18e71c9d..1de369fb 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -318,6 +318,7 @@ async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistan assert result["description_placeholders"]["total"] == "9" # Two learned so far, seven still to walk through on the appliance. assert result["description_placeholders"]["found"] == "2" + _observe_program_load(coordinator, SPORTS) assert coordinator.cloud_courses.download_candidates() == ["87"] @@ -420,6 +421,23 @@ async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAss # --------------------------------------------------------------------------- +def _observe_program_load(coordinator, blob, course="87"): + """Stand in for the user loading a program while Home Assistant watches. + A candidate Download course comes only from a transition that was + actually observed, so tests that expect one have to produce it.""" + coordinator._observe.apply( + COURSE, + { + "x.com.samsung.da.options": [ + "CloudExtraCourse_0A5C286B2D0C55301A", + f"Course_{course}", + f"OneTimeCloudCourse_{blob}", + ] + }, + source="poll", + ) + + def _stub_probe(coordinator, sequence): """Drive async_probe_cloud_courses from a scripted list of loaded slots, standing in for the user turning the dial. The last entry repeats.""" @@ -658,7 +676,9 @@ async def test_guided_setup_alone_produces_a_selectable_cycle(hass: HomeAssistan first = await handler.async_step_cloud_name() assert "download_course" in str(first["data_schema"].schema) - # Prefilled from the observation the fixture already carries. + # Prefilled once a load has actually been watched -- in real use the + # guided probe loop feeds observe() and produces exactly this. + _observe_program_load(coordinator, SPORTS) assert handler._cloud_course == "87" await handler.async_step_cloud_name({"name": "Sports", "download_course": "87"}) From 9652a14f648ee614b51722373a320d6fe4d80804 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 10:26:33 +0000 Subject: [PATCH 13/14] tests: stop the cloud write test leaving a debounced refresh behind A write schedules a debounced refresh, which polled through a fake session that only implements post(), crashed on the missing get(), and left its timer running past the end of the test. CI's lingering-timer check caught it; it passed locally only by timing luck. Stubbed the same way test_coordinator_send_command's fixture already does, which is what this test should have copied to begin with. --- tests/test_cloud_courses_flow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 1de369fb..74f92ee2 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -13,6 +13,7 @@ import contextlib import json from typing import Any, cast +from unittest.mock import AsyncMock import cbor2 import pytest @@ -164,7 +165,14 @@ def post(self, path_segs, payload, timeout=None): sent.append((path_segs, payload)) return 0x44, b"" + def pace(self): + pass + coordinator._session = cast(Any, _FakeSession()) + # Same shape as test_coordinator_send_command's fixture: a write schedules + # a debounced refresh, which would poll through this fake and leave its + # timer behind after the test. + coordinator.async_request_refresh = AsyncMock() bound = next(b for b in coordinator.bound if b.desc.key == "cycle") await coordinator.async_send_command(bound, "cloud:55") From 0dd8bfb5fc5fbbe2e0fd0645b3f61af3581de659 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 10:43:01 +0000 Subject: [PATCH 14/14] laundry: fix four findings from the final review of guided setup The one that could run the wrong program: guided setup accepted a name another program already had. The duplicate check only compared names within the form it was handed, and guided setup submits one program at a time, so it never saw the others. Two programs sharing a label resolve to whichever option comes first, so picking the second would have run the first one's payload -- the exact failure the check exists to prevent, working correctly in the bulk form and blind in the guided one. The taken set now includes every other named program, excluding the slot being edited so confirming an unchanged name doesn't reject itself. The rest: - The timeout screen's copy interpolates the same counters as the other two but was shown without placeholders, so it rendered literal braces. - The probe reports whatever payload is loaded, while observe() declines one whose slot the device doesn't advertise. Guided setup could reach the name form for such a slot, take a name, and silently discard it -- there is no record to hang it on and no payload to replay. It now waits instead. - The prefilled Download course came from the raw candidate list while the dropdown filters to courses the appliance still offers, so a stale candidate prefilled a value the selector rejects and the form failed validation on something the user never chose. --- custom_components/localthings/config_flow.py | 36 +++++++++- tests/test_cloud_courses_flow.py | 75 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f7fb65bf..d965d7b9 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -1005,7 +1005,16 @@ async def _await_cloud_selection(self, coord) -> str | None: deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S while time.monotonic() < deadline: slot = await coord.async_probe_cloud_courses() - if slot is not None and slot != self._cloud_baseline: + # Only a slot the store actually recorded. The probe reports + # whatever payload is loaded, while observe() declines one whose + # slot the device doesn't advertise -- offering to name that would + # take a name and silently discard it, since there is no record to + # hang it on and no payload to replay. + if ( + slot is not None + and slot != self._cloud_baseline + and coord.cloud_courses.snapshot()["slots"].get(slot) + ): return slot await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S) return None @@ -1104,11 +1113,17 @@ def _cloud_name_form( @property def _cloud_course(self) -> str | None: + """The best observed candidate, narrowed to what the selector offers. + + Unfiltered, a candidate the appliance's own course list no longer + contains would prefill a dropdown that rejects it, and the form would + fail validation on a value the user never chose. + """ coord = self._coordinator() if coord is None: return None - candidates = coord.cloud_courses.download_candidates() - return candidates[0] if candidates else None + available = cycle_options(coord.canonical_resources(MAIN)) + return next((c for c in coord.cloud_courses.download_candidates() if c in available), None) def _cloud_course_selector(self, coord): """The appliance's own course codes, observed candidates first. @@ -1134,9 +1149,13 @@ async def async_step_cloud_timeout( """Nothing was selected in time. Offer another round rather than dropping the user out of the flow -- and an explicit finish, for anyone who doesn't think to close the dialog.""" + coord = self._coordinator() + if coord is None: + return self.async_abort(reason="not_loaded") return self.async_show_menu( step_id="cloud_timeout", menu_options=["cloud_guided", "cloud_finish"], + description_placeholders=self._cloud_progress_placeholders(coord), ) async def async_step_cloud_finish( @@ -1204,7 +1223,18 @@ def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: courses come first, so a shared label resolves to the real cycle). """ names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known} + stored_slots = coord.cloud_courses.snapshot()["slots"] taken = {name.casefold() for name in self._device_course_names(coord)} + # Programs this form isn't editing. The bulk form edits every slot at + # once so this adds nothing there, but guided setup submits one at a + # time -- without it, naming two programs the same was accepted, and + # the select resolves a shared label to whichever option comes first, + # so picking the second would run the first one's payload. + taken |= { + record["name"].casefold() + for slot, record in stored_slots.items() + if record["name"] and slot not in known + } for name in names.values(): if not name: continue diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 74f92ee2..3a676bc9 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -709,3 +709,78 @@ async def test_guided_rejects_a_download_course_the_device_does_not_offer(hass: result = await handler.async_step_cloud_name({"name": "Sports", "download_course": "FF"}) assert result["errors"] == {"base": "cloud_course_unknown_course"} assert coordinator.cloud_courses.snapshot()["download_course"] is None + + +async def test_guided_rejects_a_name_another_program_already_has(hass: HomeAssistant): + """Guided setup submits one program at a time, so a within-form check + can't see the others. Two programs sharing a label resolve to whichever + option comes first, meaning picking the second would run the first one's + payload.""" + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"6B": "Sports"}, "87") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + result = await handler.async_step_cloud_name({"name": "Sports"}) + await _flush(hass) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} + assert coordinator.cloud_courses.named() == {"6B": "Sports"} + + +async def test_renaming_a_program_to_its_own_name_is_allowed(hass: HomeAssistant): + """The slot being edited is excluded from the taken set, or confirming an + unchanged name would reject itself.""" + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"55": "Sports"}, "87") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + _stub_probe(coordinator, ["55"]) + await _run_wait(handler) + + result = await handler.async_step_cloud_name({"name": "Sports"}) + assert result["type"] == "progress" # accepted, straight back to waiting + + +async def test_the_timeout_screen_fills_in_its_counters(hass: HomeAssistant): + """Its copy interpolates the same placeholders as the other screens, so + without them the dialog renders literal braces.""" + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"55": "Sports"}, "87") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_cloud_timeout() + assert result["description_placeholders"]["named"] == "1" + assert result["description_placeholders"]["named_list"] == "Sports" + + +async def test_guided_ignores_a_payload_the_store_would_not_record(hass: HomeAssistant): + """The probe reports whatever payload is loaded; observe() declines one + whose slot the device doesn't advertise. Offering to name that would take + a name and discard it -- there is no record to hang it on.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + await handler.async_step_cloud_guided() + + # 'ZZ' is not in this appliance's CloudExtraCourse_ list. + _stub_probe(coordinator, ["ZZ"]) + result = await _run_wait(handler) + assert result["step_id"] == "cloud_timeout" + + +async def test_the_prefilled_course_is_always_one_the_dropdown_offers(hass: HomeAssistant): + """An observed candidate the appliance's course list no longer contains + would prefill a value the selector rejects, failing validation on + something the user never chose.""" + coordinator = await _coordinator(hass) + coordinator.cloud_courses._candidates["FF"] = 99 # not in the course list + handler = await _options_handler(hass, coordinator) + + offered = handler._cloud_course_selector(coordinator).config["options"] + assert handler._cloud_course is None or handler._cloud_course in offered