From b921bdbb280176a34200e720dfb413b67c7508f7 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 10 Aug 2026 03:16:07 +0000 Subject: [PATCH] laundry: fix six issues from review of the cloud-cycle branch Also drops appliance-specific wording from the new user-facing strings. The setup step said "your washer" and told people to "turn the dial", which is wrong for the DW5000C dishwasher that advertises the same tokens. The two that could have caused a wrong wash cycle: - The Download-course candidate was counted on every poll that saw a loaded one-time payload, not on the polls where one was actually loaded. Since a stale token is never evicted, it keeps being reported through however long the appliance then sits on some ordinary course -- so "most frequent" ranked by dwell time. Reproduced: one poll on Course_87 then 200 on Course_1B suggests 1B, and accepting the suggestion makes picking a download program start a Cotton wash. Now only a change of the payload counts, which is the moment the device is known to accept a program. - The Download-course dropdown had custom_value=True, contradicting its own comment, so a typed-in code went into the Course_ token of a real write unchecked. Off now, plus a server-side check against the device's own course list where the value is stored. Two that quietly broke things beyond this feature: - cycle_select now always supplies a display_fn (to label cloud programs), which defeated select._display's "no state table and no fallback -> return raw" exit. Every dryer, dishwasher and air dresser on an unrecognized course table would have had its options and state reshaped from '0E' to '0 E', breaking automations and recorder history. The exit now keys off whether anything actually named the value, not whether a fallback existed. - The synthetic cloud field reached diagnostics, which reads canonical_resources -- publishing user-typed program names in a dump people paste into issues, directly against the comment claiming it never could. Dropped at the redaction boundary, with a matching strip for the debug read service, which wants device state unredacted but shouldn't present our bookkeeping as something the appliance said. And two smaller ones: - The repair fired on any device advertising slots, so the DW5000C -- four advertised, none ever loaded -- got a permanent warning nothing the owner did in Home Assistant could clear. It now waits until a payload has been seen, which is the only evidence that household uses downloaded programs. - The name-collision check read only the translation catalog, missing the device's own personal-course labels, which the select renders identically. --- custom_components/localthings/cloudcourse.py | 42 ++++++--- custom_components/localthings/config_flow.py | 38 ++++++-- custom_components/localthings/coordinator.py | 16 +++- .../registry/capabilities/laundry.py | 10 ++- .../localthings/registry/redact.py | 32 ++++++- custom_components/localthings/select.py | 21 +++-- custom_components/localthings/services.py | 9 +- .../localthings/translations/cs.json | 5 +- .../localthings/translations/de.json | 5 +- .../localthings/translations/en.json | 5 +- .../localthings/translations/es.json | 5 +- .../localthings/translations/it.json | 5 +- .../localthings/translations/ko.json | 5 +- .../localthings/translations/nl.json | 5 +- tests/test_cloud_courses.py | 21 +++++ tests/test_cloud_courses_flow.py | 89 +++++++++++++++++++ tests/test_select_display.py | 16 ++++ 17 files changed, 279 insertions(+), 50 deletions(-) diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index 3780b18..3d4af4a 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -20,6 +20,13 @@ and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot 2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one reason nothing here ever synthesizes one. +This is not washer-only, and nothing here assumes an appliance type: the +DW5000C dishwasher in ``tests/fixtures`` advertises four slots on the same +token. It also carries no payload token at all, so a device can name +programs whose payloads have never been observed -- ``advertised_slots`` +answers "which exist", the store answers "which are usable", and the two +are deliberately allowed to disagree. + What this module does and deliberately does not do -------------------------------------------------- The device advertises *which* slots exist but never what any of them is @@ -189,10 +196,13 @@ class CloudCourses: download, slots = _coerce(stored_record) self._download_course = download self._slots = slots - # Course codes seen while a one-time override was actually loaded -- + # Course codes seen at the moment a one-time override was *loaded* -- # candidates for "which course means Download on this board", pending # user confirmation (see download_candidates). self._candidates: dict[str, int] = {} + # Last one-time payload seen, so a load can be told from a poll that + # merely re-reports one. None means "nothing observed yet". + self._last_oneshot: str | None = None # -- learning --------------------------------------------------------- @@ -201,13 +211,20 @@ class CloudCourses: Two facts are learnable here. A blob is recorded against the slot its own byte 2 names, so a program only has to be sitting loaded once -- - on either token -- to be replayable forever after. The Download course - code is only ever taken as a *candidate*: tokens in this array are - replaced by prefix and never evicted, so a stale - OneTimeCloudCourse_ can outlive the run it belonged to and be - reported alongside an unrelated local course. Confirming the code is - the user's call in the options flow -- a wrong one would write a real - wash cycle when someone picked a download program. + on either token -- to be replayable forever after. + + The Download course code is only ever taken as a *candidate*, and + only at the moment the one-time payload actually *changes* to a + loaded value. That instant is the one the device is known to accept a + program on, so the course selected then is real evidence. Counting + every poll instead would rank by dwell time: tokens in this array are + replaced by prefix and never evicted, so a stale OneTimeCloudCourse_ + outlives its run and sits there through however many polls the + appliance spends on some ordinary course afterwards -- which is + exactly the course that would then be suggested. A candidate is still + never applied without confirmation in the options flow, but a + confident wrong suggestion is most of the way to a wrong write, and a + wrong write here starts a real wash cycle. """ options = rep.get("x.com.samsung.da.options") if not options: @@ -231,8 +248,9 @@ class CloudCourses: changed = True oneshot = option_value(options, ONESHOT_PREFIX) course = option_value(options, COURSE_PREFIX) - if course and is_loaded(oneshot): + if course and is_loaded(oneshot) and oneshot != self._last_oneshot: self._candidates[course] = self._candidates.get(course, 0) + 1 + self._last_oneshot = oneshot return changed # -- reads ------------------------------------------------------------ @@ -242,9 +260,9 @@ class CloudCourses: return self._download_course def download_candidates(self) -> list[str]: - """Course codes seen alongside a loaded one-time override, most - frequent first -- what the options flow offers as the likely Download - course. Never used for a write on its own.""" + """Course codes seen at the moment a one-time program was loaded, + most-observed first -- what the options flow offers as the likely + Download course. Never used for a write on its own.""" with self._lock: ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0])) return [code for code, _ in ranked] diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 8f8b519..b52f93f 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -64,7 +64,7 @@ from .const import ( ) from .learned import persist as learned_persist from .learned import stored as learned_stored -from .registry.capabilities.laundry import cycle_options +from .registry.capabilities.laundry import cycle_options, personal_course_labels from .registry.subdevices import MAIN _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) @@ -967,17 +967,27 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): return {"base": "cloud_course_name_duplicate"} taken.add(name.casefold()) + # Belt and braces over the selector's own custom_value=False: this + # value becomes the Course_ token of a real write, so it is checked + # against the appliance's own course list here too, where the store + # is actually updated. + course = user_input.get("download_course") or None + if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)): + return {"base": "cloud_course_unknown_course"} + for slot, name in names.items(): coord.cloud_courses.set_name(slot, name) - coord.set_cloud_download_course(user_input.get("download_course") or None) + coord.set_cloud_download_course(course) return {} def _local_course_names(self, coord) -> set[str]: - """Translated display names of this appliance's own local courses. + """Display names of this appliance's own local courses. - Read through the same catalog the select renders from, so the check - matches what the user will actually see side by side in the dropdown. - An untranslated code has no display name to collide with. + Read through the same two sources the select renders from -- the + translation catalog, and the device's own personal-course labels + (laundry.washer_cycle_fallback) -- so the check matches what the user + will actually see side by side in the dropdown. A code neither source + names has no display name to collide with. """ bound = next( (b for b in coord.bound if b.desc.key == "cycle" and b.href == cloudcourse.COURSE_HREF), @@ -990,7 +1000,14 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): if callable(key): key = key(resources) labels = translated_state_labels("select", key) if key else {} - return {labels[code.lower()] for code in cycle_options(resources) if code.lower() in labels} + personal = personal_course_labels(resources) + names = set() + for code in cycle_options(resources): + if (catalogued := labels.get(code.lower())) is not None: + names.add(catalogued) + if (own := personal.get(code.upper())) is not None: + names.add(own) + return names def _cloud_courses_form( self, coord, rep, known, advertised, errors: dict[str, str] | None = None @@ -1007,7 +1024,10 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): # Course codes this device actually offers, so the Download course # can only ever be set to one of them. Auto-detected candidates come - # first -- see CloudCourses.download_candidates. + # first -- see CloudCourses.download_candidates. custom_value stays + # off deliberately: whatever lands here becomes the Course_ token of a + # real write, and a typed-in code the appliance doesn't offer would + # start something nobody chose. available = cycle_options(coord.canonical_resources(MAIN)) candidates = [c for c in store.download_candidates() if c in available] ordered = candidates + [c for c in available if c not in candidates] @@ -1016,7 +1036,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): SelectSelector( SelectSelectorConfig( options=ordered, - custom_value=True, + custom_value=False, mode=SelectSelectorMode.DROPDOWN, ) ) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 2977fbe..3121431 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -496,12 +496,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): has, so the gap between that and what's usable is knowable -- and it can only be closed by the user walking the appliance through its own Download list, which is exactly what a Repair is for. + + Gated on having seen at least one payload, which is the only + available evidence that this household uses downloaded programs at + all. Without it, an appliance that merely advertises slots -- the + DW5000C fixture reports four and has never loaded any -- would carry + a permanent warning about a feature its owner may never touch, and + nothing they do in Home Assistant could clear it. Running one + downloaded program is what turns the nudge on, and naming them is + what turns it off. """ issue_id = f"cloud_courses_{self._entry.entry_id}" rep = self.cloud_course_rep() - pending = cloudcourse.undiscovered(rep, self._cloud.snapshot()) - needs_course = bool(cloudcourse.advertised_slots(rep)) and not self._cloud.download_course() - if pending or needs_course: + record = self._cloud.snapshot() + pending = cloudcourse.undiscovered(rep, record) + needs_course = bool(cloudcourse.advertised_slots(rep)) and not record["download_course"] + if record["slots"] and (pending or needs_course): ir.async_create_issue( self.hass, DOMAIN, diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index c66618f..19d69eb 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -335,12 +335,16 @@ def option_write(prefix, new_value): # receive. # # They ride in the cycle select rather than a select of their own because -# that is what they are to a user -- on the appliance's own dial, "Download" -# occupies one position among the ordinary courses, and picking a downloaded -# program is picking a cycle. Their raw values are namespaced +# that is what they are to a user -- on the appliance's own controls, +# "Download" occupies one position among the ordinary courses, and picking a +# downloaded program is picking a cycle. Their raw values are namespaced # ('cloud:') so they can never be confused with, or collide with, a # two-hex-char local course code. # +# Bound by whichever families declare it. Washers are where this was worked +# out, but a DW5000C dishwasher advertises the same token (see +# cloudcourse.py), so nothing below is washer-specific. +# # Confirmed on hardware before any of this was written (issue #342): writing # the program token alone, while some other course is selected, is silently # ignored -- the course token has to switch to Download in the *same* write. diff --git a/custom_components/localthings/registry/redact.py b/custom_components/localthings/registry/redact.py index 1ed8e47..e9fd5ca 100644 --- a/custom_components/localthings/registry/redact.py +++ b/custom_components/localthings/registry/redact.py @@ -39,6 +39,14 @@ _SENSITIVE_SUBSTRINGS = ( _SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) +# Fields this integration merges onto a rep for its own use, which the +# device never reported (see coordinator.entity_resources). A diagnostics +# dump is meant to be exactly what the appliance said, so these are dropped +# rather than redacted -- keeping them would both misrepresent the device and +# publish data the user typed (cloud program names are user-supplied). +_SYNTHETIC_KEY_PREFIX = "x.localthings." + + def _is_sensitive_key(key: str) -> bool: lowered = key.lower() if lowered in _SENSITIVE_EXACT: @@ -46,8 +54,29 @@ def _is_sensitive_key(key: str) -> bool: return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) +def strip_synthetic(resources): + """Drop this integration's own merged-in fields, leaving only what the + appliance actually reported. + + Separate from redact_resources because the two answer different + questions: the debug read service wants the device's unredacted state + (serial and all -- that is the point of it) but still shouldn't present + our own bookkeeping as something the device said. + """ + if isinstance(resources, dict): + return { + key: strip_synthetic(value) + for key, value in resources.items() + if not key.startswith(_SYNTHETIC_KEY_PREFIX) + } + if isinstance(resources, list): + return [strip_synthetic(item) for item in resources] + return resources + + def redact_resources(resources): - """Recursively redact dict values whose key matches a sensitive substring. + """Recursively redact dict values whose key matches a sensitive substring, + and drop this integration's own synthetic fields entirely. Works on the shape produced by parse_device0_batch (dict[href, rep]) or any nested dict/list structure within a rep. @@ -56,6 +85,7 @@ def redact_resources(resources): return { key: (REDACTED if _is_sensitive_key(key) else redact_resources(value)) for key, value in resources.items() + if not key.startswith(_SYNTHETIC_KEY_PREFIX) } if isinstance(resources, list): return [redact_resources(item) for item in resources] diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 2ca918a..d405cd5 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -67,22 +67,29 @@ def _display(value, translation_key: str | None, fallback_fn=None): """ if not isinstance(value, str): return value + # No state table for this key: either the entity isn't translated at all, + # or its name is translated but its options deliberately aren't (an + # unrecognized course table, say). + uncatalogued = False if translation_key: known = translated_states("select", translation_key) if not known: - # No state table for this key: either the entity isn't translated - # at all, or its name is translated but its options deliberately - # aren't (an unrecognized course table, say). Give an explicit - # device-specific fallback the opportunity to make an opaque value - # readable; otherwise the raw device value remains the best choice. - if fallback_fn is None: - return value + uncatalogued = True elif translated := _translation_state(value, known): return translated if fallback_fn is not None: fallback = fallback_fn(value) if fallback is not None: return fallback + if uncatalogued: + # Nothing could name this value: not the catalog, and not a + # device-specific fallback (either absent, or present and declining + # to label this one). The raw device value is the best choice -- + # cosmetic reshaping below would only mangle an opaque code, turning + # a course '0E' into '0 E'. Keyed on the fallback's *result*, not on + # whether one was supplied: cycle_select always supplies one now, to + # label cloud programs, and it returns None for everything else. + return value if value.islower(): return value.replace("_", " ").title() return _CAMEL_BOUNDARY_RE.sub(" ", value) diff --git a/custom_components/localthings/services.py b/custom_components/localthings/services.py index c124b9d..fa2c024 100644 --- a/custom_components/localthings/services.py +++ b/custom_components/localthings/services.py @@ -24,6 +24,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE from .coordinator import LocalThingsCoordinator, normalize_href +from .registry.redact import strip_synthetic from .registry.subdevices import MAIN, Subdevice ATTR_HREF = "href" @@ -170,7 +171,13 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic # href: lets a user enumerate what exists without hammering the # device (see this module's docstring and the coordinator's # canonical_resources). - snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)} + # Stripped, not redacted: this response is meant to be what the + # appliance reported (unredacted -- that is the point of a debug + # read), but canonical_resources also carries fields this integration + # merged on for its own use (see entity_resources). + snapshot: dict[str, Any] = { + "resources": strip_synthetic(coordinator.canonical_resources(subdevice)) + } return cast(ServiceResponse, snapshot) # Same normalize-before-translate order as the write path above. diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index b6e5db2..7f158fe 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Zadejte alespoň jedno pole k zápisu.", "write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte." diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index f76fcb8..b9b5369 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Geben Sie mindestens ein zu schreibendes Feld ein.", "write_failed": "Der Schreibvorgang ist fehlgeschlagen. Weitere Details finden Sie im Home-Assistant-Protokoll.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde." diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 16da390..8c26a13 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Enter at least one field to write.", "write_failed": "The write failed. Check the Home Assistant logs for details.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "This device isn't connected yet. Try again once it has loaded." diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 226b25c..ca3b6a0 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -95,7 +95,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -104,7 +104,8 @@ "error": { "empty_payload": "Introduce al menos un campo para escribir.", "write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado." diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 9735821..88f0c11 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Inserisci almeno un campo da scrivere.", "write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento." diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 675c2be..d01a370 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "쓸 필드를 하나 이상 입력하세요.", "write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요." diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index e5b4eb6..04d2884 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1448,7 +1448,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.", + "description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.", "data": { "download_course": "Download cycle course code" } @@ -1457,7 +1457,8 @@ "error": { "empty_payload": "Voer ten minste één veld in om te schrijven.", "write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.", - "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique." + "cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.", + "cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list." }, "abort": { "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index 29956f1..599827d 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -207,6 +207,27 @@ class TestStoreRules: assert store.observe(rep) is True assert store.observe(rep) is False + def test_candidates_rank_by_program_loads_not_by_dwell_time(self): + """A stale OneTimeCloudCourse_ token outlives the run it belonged to, + so it is still reported through every poll the appliance spends on + whatever ordinary course comes next. Counting polls would rank that + course first and suggest it as the Download course -- and accepting + the suggestion would start a real wash cycle. Only the poll where the + payload actually changed is evidence.""" + store = cloudcourse.CloudCourses() + loaded = _rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]) + stale = _rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]) + store.observe(loaded) + for _ in range(200): # ~100 minutes sitting on a cotton cycle + store.observe(stale) + assert store.download_candidates() == ["87"] + + def test_a_second_distinct_load_is_counted(self): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])) + store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{JEANS}"])) + assert store.download_candidates() == ["87"] + def test_view_is_empty_until_a_download_course_is_confirmed(self): """Both halves are required: without the course code there is no write to build, so nothing should reach the select.""" diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index ab0b738..272f7c2 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -10,6 +10,7 @@ flow that supplies the names. from __future__ import annotations import asyncio +import json from typing import Any, cast import cbor2 @@ -21,6 +22,7 @@ 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 @@ -320,3 +322,90 @@ async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistan # Two learned so far, seven still to walk through on the appliance. assert result["description_placeholders"]["found"] == "2" assert coordinator.cloud_courses.download_candidates() == ["87"] + + +async def test_no_repair_until_a_program_has_actually_been_seen(hass: HomeAssistant): + """The DW5000C advertises four downloaded programs and has never loaded + one, so nothing about them is learnable and no name field can be offered. + Warning its owner about a feature they may never use -- with no action + that could clear it -- is worse than staying quiet until they run one.""" + entry = _entry(hass) + coordinator = LocalThingsCoordinator(hass, entry) + resources = _load_device("dishwasher_dw5000c_cloud") + coordinator._run_discovery(resources) + for href, rep in resources.items(): + coordinator._observe.apply(href, rep, source="poll") + coordinator._refresh_cloud_course_issue() + await _flush(hass) + + assert cloudcourse.advertised_slots(coordinator.cloud_course_rep()) == ["8E", "8D", "8F", "02"] + assert coordinator.cloud_courses.snapshot()["slots"] == {} + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None + + +async def test_the_synthetic_field_never_reaches_diagnostics( + hass: HomeAssistant, + enable_custom_integrations, +): + """canonical_resources carries it so entity descriptors can read it, and + diagnostics reads canonical_resources -- so the drop has to happen at the + redaction boundary. A dump is meant to be what the appliance said, and + cloud program names are text the user typed.""" + from custom_components.localthings.diagnostics import ( + async_get_config_entry_diagnostics, + ) + + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Marc's weekend towels") + await _flush(hass) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + dump = json.dumps(await async_get_config_entry_diagnostics(hass, entry)) + assert cloudcourse.FIELD not in dump + assert "Marc's weekend towels" not in dump + # The device's own tokens are still there -- only our field is dropped. + assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump + + +async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): + from custom_components.localthings.registry.redact import strip_synthetic + + coordinator = await _coordinator(hass) + coordinator.set_cloud_download_course("87") + coordinator.set_cloud_course_name("55", "Sports") + await _flush(hass) + + stripped = strip_synthetic(coordinator.canonical_resources(MAIN)) + assert cloudcourse.FIELD not in stripped[COURSE] + assert "x.com.samsung.da.options" in stripped[COURSE] + + +async def test_the_flow_rejects_a_download_course_the_device_does_not_offer( + hass: HomeAssistant, +): + """Whatever lands here becomes the Course_ token of a real write.""" + coordinator = await _coordinator(hass) + handler = await _options_handler(hass, coordinator) + + result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"}) + assert result["errors"] == {"base": "cloud_course_unknown_course"} + assert coordinator.cloud_courses.download_course() is None + assert coordinator.cloud_courses.named() == {} + + +async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAssistant): + """Personal course names come from the device, not the catalog, and the + select renders them the same way -- so they collide the same way.""" + coordinator = await _coordinator(hass) + # 'MyCo' as a personal course label on a code the device offers. + rep = dict(coordinator.resource("/wm/personalcourse/vs/0") or {}) + rep["x.com.samsung.da.courses"] = ["1C_01044D79436F"] + coordinator._observe.apply("/wm/personalcourse/vs/0", rep, source="poll") + await _flush(hass) + + handler = await _options_handler(hass, coordinator) + result = await handler.async_step_cloud_courses({"name_55": "MyCo", "download_course": "87"}) + assert result["errors"] == {"base": "cloud_course_name_duplicate"} diff --git a/tests/test_select_display.py b/tests/test_select_display.py index 1a9165d..212de65 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"