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.
This commit is contained in:
Marc Billow
2026-08-10 03:16:07 +00:00
parent 9d28b088cb
commit b921bdbb28
17 changed files with 279 additions and 50 deletions
+30 -12
View File
@@ -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]
+29 -9
View File
@@ -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,
)
)
+13 -3
View File
@@ -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,
@@ -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:<slot>') 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.
@@ -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]
+14 -7
View File
@@ -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)
+8 -1
View File
@@ -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.
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
@@ -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."
+21
View File
@@ -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."""
+89
View File
@@ -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"}
+16
View File
@@ -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"