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"