diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py new file mode 100644 index 00000000..70fdaacb --- /dev/null +++ b/custom_components/localthings/cloudcourse.py @@ -0,0 +1,377 @@ +"""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. + +``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 +-------------------------------------------------- +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 +from .registry.capabilities.common import hex_pairs, option_value + +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" + +# 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 + + +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 hex_pairs(blob.upper()) + + +def is_loaded(blob) -> bool: + """True when `blob` names an actual program rather than 'none'.""" + 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.""" + slot, loaded = _slot_and_loaded(blob) + return slot if loaded else 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]: + """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 [] + # 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(hex_pairs(raw.upper()))) + + +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 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 + 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 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 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. _UNOBSERVED until the first rep arrives. + self._last_oneshot: object = _UNOBSERVED + + # -- 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*, 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: + return False + known_slots = advertised_slots(rep) + 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) + # 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": ""} + else: + record["blob"] = blob.upper() + changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()} + + course = option_value(options, COURSE_PREFIX) + # 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 + + # -- reads ------------------------------------------------------------ + + 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 + 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 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 self._is_usable(record) + }, + } + + # -- 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() + + @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, 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 [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 97c76fb6..d965d7b9 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 @@ -34,6 +35,7 @@ TextSelectorType, ) +from . import cloudcourse 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, personal_course_labels +from .registry.subdevices import MAIN _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) _MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)) @@ -74,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" @@ -827,15 +839,27 @@ 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) 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(), cycle_options(coord.canonical_resources(MAIN)) + ): + 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 +925,385 @@ 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: + """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() + # 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 + + 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) + 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 + ) -> 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: + # 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 + # 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. + + 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(fields), + errors=errors or {}, + description_placeholders=placeholders, + last_step=False, + ) + + @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 + 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. + + 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: + """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( + 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). + + 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.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] + + 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, known, advertised, errors=errors) + + 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. + + 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} + 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 + if name.casefold() in taken: + 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 + # 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"} + + coord.apply_cloud_courses(names, course) + return {} + + def _device_course_names(self, coord) -> set[str]: + """Course names this appliance reports itself. + + 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. + """ + resources = coord.canonical_resources(MAIN) + personal = personal_course_labels(resources) + return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))} + + def _cloud_courses_form( + self, coord, 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 + ) + + suggested = record["download_course"] or self._cloud_course + fields[vol.Optional("download_course", description={"suggested_value": suggested})] = ( + self._cloud_course_selector(coord) + ) + + pending = [s for s in advertised if s not in slots] + return self.async_show_form( + step_id="cloud_manual", + 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..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 @@ -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, @@ -52,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 ( @@ -62,6 +67,7 @@ resolve_serial, ) from .registry.subdevices import ( + MAIN, Subdevice, canonical_view, discover_partitioned, @@ -69,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"] @@ -259,6 +269,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 +325,61 @@ 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 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.last_resources + 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 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 @@ -325,7 +393,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 +457,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 +478,134 @@ 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 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) + + 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: 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) + if download_course is not _KEEP: + self._cloud.set_download_course(cast("str | None", download_course)) + 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. + + 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() + record = self._cloud.snapshot() + 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, + 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(slots)), + }, + 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 +708,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 +1270,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 +1299,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 +1347,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 +1371,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 +1393,12 @@ 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_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, 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..b61124b8 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -16,8 +16,10 @@ 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.capabilities.laundry import cycle_options from .registry.redact import redact_resources from .registry.subdevices import MAIN @@ -32,6 +34,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 +57,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 +91,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 +140,25 @@ def _subdevice_diag(su) -> dict: "enabled": coordinator.learning_enabled, "codes": coordinator.learned_snapshot(), }, + # Cloud "Download" programs discovered on this device (issue #342), + # 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()), + "cloud_slots": cloudcourse.cloud_slots( + coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN)) + ), + **cloud_courses, + }, "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 42058029..db959e8c 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -23,9 +23,11 @@ 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 +from .common import hex_pairs, option_value _LED_LEVELS = ("Low", "High") _SOUND_MODES = ("voice", "tone", "mute") @@ -198,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: @@ -218,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. @@ -309,20 +298,124 @@ 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 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. +# Hence the two-token write, the only one in this module. + + +def _cloud_state(rep): + return rep.get(cloudcourse.FIELD) or {} + + +def cloud_options(rep): + """Namespaced raw values for every named, learned cloud program.""" + return [ + f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {})) + ] + + +def cloud_label(value, resources): + """The user's own name for a 'cloud:' value. + + Cloud program names are user-supplied, never translated: the appliance + reports only an opaque slot id, and inventing an English label for one + is exactly what this module refuses to do for unrecognized local course + codes (see washer_cycle_fallback). + """ + if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX): + return None + slot = value[len(cloudcourse.RAW_PREFIX) :] + rep = resources.get(cloudcourse.COURSE_HREF) or {} + program = _cloud_state(rep).get("programs", {}).get(slot) + return program["name"] if program else None + + +def cloud_current(rep): + """'cloud:' when a named cloud program is the live selection. + + Gated on the course actually being this device's confirmed Download + course: tokens in this array are replaced by prefix and never evicted, so + a one-time program token outlives the run it belonged to and would + otherwise report "Jeans" while an ordinary cotton cycle runs. + """ + state = _cloud_state(rep) + download = state.get("download_course") + options = rep.get("x.com.samsung.da.options") + if not download or option_value(options, "Course") != download: + return None + blob = option_value(options, cloudcourse.ONESHOT_PREFIX) + slot = cloudcourse.slot_of(blob) + if slot is None: + # No one-time override loaded: the appliance falls back to whatever + # the persisted default holds (confirmed with the issue #342 + # reporter -- leaving Download and returning to it re-selects the + # saved program, not the last one-time one). + slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX)) + if slot is None or slot not in state.get("programs", {}): + return None + return f"{cloudcourse.RAW_PREFIX}{slot}" def cycle_write(p, rep, href=None): 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 +492,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 +508,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(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 + # 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/select.py b/custom_components/localthings/select.py index 2ca918a2..1038f003 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -67,22 +67,21 @@ def _display(value, translation_key: str | None, fallback_fn=None): """ if not isinstance(value, str): return value - 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 - 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 + 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() return _CAMEL_BOUNDARY_RE.sub(" ", value) diff --git a/custom_components/localthings/services.py b/custom_components/localthings/services.py index c124b9d7..8220c6ee 100644 --- a/custom_components/localthings/services.py +++ b/custom_components/localthings/services.py @@ -170,7 +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). - snapshot: dict[str, Any] = {"resources": 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/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 95c2e5f1..00b9d405 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": "Stažené cykly", "forget_learned_modes": "Zapomenout zapamatované režimy", "debug_write": "Ladění: zápis do prostředku" } @@ -1444,20 +1445,63 @@ "debug_write": "Zapsat do dalšího prostředku", "finish": "Dokončit" } + }, + "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": "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": "Průvodce nastavením", + "cloud_manual": "Upravit názvy" + } + }, + "cloud_wait": { + "title": "Stažené cykly" + }, + "cloud_name": { + "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": "Název", + "download_course": "Kód programu „Stažený program“" + } + }, + "cloud_timeout": { + "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": "Počkat znovu", + "cloud_finish": "Dokončit" + } } }, "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": "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." + }, + "progress": { + "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": { "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": "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 254f3316..4c493edf 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-Programme", "debug_write": "Debug: In eine Ressource schreiben", "forget_learned_modes": "Gemerkte Modi vergessen" } @@ -1444,20 +1445,63 @@ "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_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-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": "Geführte Einrichtung", + "cloud_manual": "Namen bearbeiten" + } + }, + "cloud_wait": { + "title": "Download-Programme" + }, + "cloud_name": { + "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", + "download_course": "Programmcode des Download-Programms" + } + }, + "cloud_timeout": { + "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": "Erneut warten", + "cloud_finish": "Fertig" + } } }, "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": "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." + }, + "progress": { + "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": { "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": "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/en.json b/custom_components/localthings/translations/en.json index 43ce1f43..0602fe5a 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,20 +1445,63 @@ "debug_write": "Write another resource", "finish": "Finish" } + }, + "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\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", + "download_course": "Download cycle course code" + } + }, + "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.", + "menu_options": { + "cloud_guided": "Wait again", + "cloud_finish": "Finish" + } } }, "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.", + "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." + }, + "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." } }, "issues": { "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..81c3be40 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": "Ciclos descargados", "forget_learned_modes": "Olvidar los modos recordados", "debug_write": "Depuración: escribir en un recurso" } @@ -91,20 +92,63 @@ "debug_write": "Escribir otro recurso", "finish": "Finalizar" } + }, + "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": "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": "Configuración guiada", + "cloud_manual": "Editar nombres" + } + }, + "cloud_wait": { + "title": "Ciclos descargados" + }, + "cloud_name": { + "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": "Nombre", + "download_course": "Código de programa de Descarga de Programas" + } + }, + "cloud_timeout": { + "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": "Esperar de nuevo", + "cloud_finish": "Finalizar" + } } }, "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": "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." + }, + "progress": { + "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": { "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": "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 b9528b15..52378be9 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": "Cicli scaricati", "forget_learned_modes": "Dimentica le modalità memorizzate", "debug_write": "Debug: scrivi su una risorsa" } @@ -1444,20 +1445,63 @@ "debug_write": "Scrivi un'altra risorsa", "finish": "Fine" } + }, + "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": "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": "Configurazione guidata", + "cloud_manual": "Modifica nomi" + } + }, + "cloud_wait": { + "title": "Cicli scaricati" + }, + "cloud_name": { + "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": "Nome", + "download_course": "Codice programma del ciclo Scaricato" + } + }, + "cloud_timeout": { + "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": "Attendi di nuovo", + "cloud_finish": "Fine" + } } }, "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": "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." + }, + "progress": { + "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": { "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": "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 043b1d30..e04d70e0 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": "다운로드 코스", "forget_learned_modes": "기억된 모드 지우기", "debug_write": "디버그: 리소스에 쓰기" } @@ -1444,20 +1445,63 @@ "debug_write": "다른 리소스에 쓰기", "finish": "완료" } + }, + "cloud_manual": { + "title": "다운로드 코스", + "description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.", + "data": { + "download_course": "다운로드 코스 코드" + } + }, + "cloud_courses": { + "title": "다운로드 코스", + "description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.", + "menu_options": { + "cloud_guided": "안내 설정", + "cloud_manual": "이름 편집" + } + }, + "cloud_wait": { + "title": "다운로드 코스" + }, + "cloud_name": { + "title": "이 코스 이름 지정", + "description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.", + "data": { + "name": "이름", + "download_course": "다운로드 코스 코드" + } + }, + "cloud_timeout": { + "title": "선택된 코스 없음", + "description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.", + "menu_options": { + "cloud_guided": "다시 대기", + "cloud_finish": "완료" + } } }, "error": { "empty_payload": "쓸 필드를 하나 이상 입력하세요.", - "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요." + "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.", + "cloud_course_name_duplicate": "두 코스의 이름이 같습니다. 이름은 서로 달라야 합니다.", + "cloud_course_unknown_course": "이 기기가 제공하지 않는 코스입니다. 목록에서 선택하세요." }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." + }, + "progress": { + "cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다." } }, "issues": { "device_gap": { "title": "{device_name}의 기능 지원이 완전하지 않음", "description": "이 기기의 일부 기능이 아직 완전히 지원되지 않습니다. 가전제품 유형이 인식되지 않았거나, 기기가 제공하는 일부 리소스가 아직 구현되지 않았습니다. 현재 지원되는 기능은 계속 사용할 수 있습니다. 설정 > 기기 및 서비스 > {device_name} > 오른쪽 위 메뉴 > 진단 정보 다운로드로 이동하여 진단 정보를 내려받은 뒤, 연결된 이슈 양식에 첨부하면 지원 범위를 넓히는 데 도움을 줄 수 있습니다." + }, + "cloud_courses_undiscovered": { + "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 542826fa..d9ea0552 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": "Gedownloade programma's", "forget_learned_modes": "Onthouden modi vergeten", "debug_write": "Foutopsporing: naar een resource schrijven" } @@ -1444,20 +1445,63 @@ "debug_write": "Naar een andere resource schrijven", "finish": "Voltooien" } + }, + "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": "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": "Begeleide installatie", + "cloud_manual": "Namen bewerken" + } + }, + "cloud_wait": { + "title": "Gedownloade programma's" + }, + "cloud_name": { + "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": "Naam", + "download_course": "Programmacode van \"Gedownload\"" + } + }, + "cloud_timeout": { + "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": "Opnieuw wachten", + "cloud_finish": "Voltooien" + } } }, "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": "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." + }, + "progress": { + "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": { "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": "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": { diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md new file mode 100644 index 00000000..10995ed4 --- /dev/null +++ b/docs/investigations/download-cycle.md @@ -0,0 +1,279 @@ +# 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. + +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 (but see below) | **0** | — | +| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes | + +Three things follow immediately from that table: + +- **`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 + 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 + +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 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 + +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 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 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 +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 +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. 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/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..0fa71fad --- /dev/null +++ b/tests/test_cloud_courses.py @@ -0,0 +1,439 @@ +"""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"]), ["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.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() == {} + + 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(["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"] + + store = cloudcourse.CloudCourses() + store.observe(rep) + 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 + 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_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) + + assert cloudcourse.advertised_slots(rep) == ["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 + 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.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 + 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() + store.observe(rep) + # Both learned slots are still unnamed, so all nine are outstanding. + 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(), courses) + assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 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 "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.snapshot()["slots"]["55"]["blob"] == 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_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(_rep(["CloudExtraCourse_55", "Course_87"])) # baseline + 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"])) # 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"] + + 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" + + 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 new file mode 100644 index 00000000..3a676bc9 --- /dev/null +++ b/tests/test_cloud_courses_flow.py @@ -0,0 +1,786 @@ +"""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 +import contextlib +import json +from typing import Any, cast +from unittest.mock import AsyncMock + +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 + +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 custom_components.localthings.registry.subdevices import MAIN +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 + +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.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 + + +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 "55" not in coordinator.cloud_courses.snapshot()["slots"] + + +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.apply_cloud_courses({"55": "Sports"}, "87") + 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.apply_cloud_courses({"6B": "Jeans"}, "87") + 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.apply_cloud_courses({"55": "Sports"}, "87") + 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.apply_cloud_courses({"55": "Sports"}, "87") + 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"" + + 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") + + 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" + + 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.apply_cloud_courses({slot: f"Program {slot}"}, "87") + 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.snapshot()["slots"]["55"]["blob"] == 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_manual() + await handler.async_step_cloud_manual( + {"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_manual( + {"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_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_manual({"name_55": "Sports", "download_course": "87"}) + await _flush(hass) + assert "cloud:55" in _cycle_options(coordinator) + + await handler.async_step_cloud_manual({"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_manual({"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_manual() + + 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" + _observe_program_load(coordinator, SPORTS) + 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.apply_cloud_courses({"55": "Marc's weekend towels"}, "87") + await _flush(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + diag = await async_get_config_entry_diagnostics(hass, entry) + 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 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): + coordinator = await _coordinator(hass) + coordinator.apply_cloud_courses({"55": "Sports"}, "87") + await _flush(hass) + + stripped = coordinator.device_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_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() == {} + + +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_manual({"name_55": "MyCo", "download_course": "87"}) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} + + +# --------------------------------------------------------------------------- +# Guided setup +# --------------------------------------------------------------------------- + + +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.""" + 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 + + +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" + + +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 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"}) + 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 + + +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 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..3af27234 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1496,3 +1496,46 @@ 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))}" + ) + + +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))}" + ) 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_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" 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