Merge pull request #347 from mbillow/claude/cloud-cycle-download-select-onx14l

Discover and offer cloud "Download" cycles (issue #342)
This commit is contained in:
Marc Billow authored and GitHub committed 2026-08-10 06:52:13 -04:00
commit 9004125c97
29 files changed
+3924 -64

No files matched your search

@@ -0,0 +1,377 @@
"""Cloud "Download" programs on a laundry device (issue #342).
Some course tables carry a course whose recipe isn't fixed in firmware --
selecting it runs whichever program was last pushed down from the
SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three
tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it:
``CloudExtraCourse_<slot><slot>...`` the device's own list of downloaded
program slots, one byte each -- the cloud counterpart of
``EditCourseList_`` for local courses.
``CloudCourse_<blob>`` the persisted default program.
``OneTimeCloudCourse_<blob>`` a this-run-only override.
A ``<blob>`` is an opaque fixed-width payload whose byte 2 is the slot id
it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers:
the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A``
enumerates nine slots matching byte 2 of all nine of its programs exactly,
and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot
``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte
2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
reason nothing here ever synthesizes one.
``CloudExtraCourse_`` does not mean the same thing on every family, so
nothing keys off it directly -- see ``cloud_slots``, which is what the rest
of this module and its callers gate on. Even then, a device can advertise a
slot whose payload has never been observed, so ``cloud_slots`` answers
"which exist" while the store answers "which are usable"; the two are
deliberately allowed to disagree.
What this module does and deliberately does not do
--------------------------------------------------
The device advertises *which* slots exist but never what any of them is
called, and never the full blob for a slot other than the one currently
loaded. A blob is only observable while the device happens to be sitting on
that program, so the full payload is *learned by observation* and persisted
(same rationale as learned.py's mode store), and the human-readable name is
supplied by the user in the options flow. Nothing is hardcoded: no catalog
of program ids, no table of blobs, no assumed Download course code. A
hardcoded catalog was considered and rejected -- a blob is cloud-assigned
per account/region, so one user's captured payload is not evidence about
anyone else's device.
Blobs are replayed byte-for-byte, exactly as captured, and never
decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode
cleanly to that program's temperature/rinse/spin, but the same offsets
produce nonsense against the WA55A7700AV blob, so that decode is recorded
in docs/investigations/download-cycle.md rather than shipped.)
"""
from __future__ import annotations
import threading
from .const import CONF_CLOUD_COURSES
from .registry.capabilities.common import hex_pairs, option_value
COURSE_HREF = "/course/vs/0"
EXTRA_PREFIX = "CloudExtraCourse"
DEFAULT_PREFIX = "CloudCourse"
ONESHOT_PREFIX = "OneTimeCloudCourse"
COURSE_PREFIX = "Course"
# Synthetic, integration-owned field the coordinator merges onto
# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all
# reach this store through their existing signatures -- rep_fn in particular
# receives only its own href's rep, never the resource snapshot, so a
# sibling-resource lookup isn't available to it. Namespaced away from
# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one,
# and merged at read time only: it is never written to the state cache, never
# sent to the device, and never part of a diagnostics dump.
FIELD = "x.localthings.cloudCourses"
# Raw-value namespace for a cloud program in the cycle select. A slot id is
# itself two hex chars, exactly like a local course code, so the two would be
# indistinguishable (and could collide outright) as bare select values.
RAW_PREFIX = "cloud:"
# A blob whose first two bytes are FFFF means "no program loaded" rather than
# naming one -- WA55A7700AV reports
# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a
# perfectly ordinary local course, and its byte 2 (01) is not one of the slots
# its own CloudExtraCourse_ advertises.
_SENTINEL_PREFIX = "FFFF"
# Distinct from None, which is a real observation that carried no payload.
# Only "never observed" suppresses a candidate; absent-then-loaded is a
# genuine transition and should count.
_UNOBSERVED = object()
# Byte offset within a blob that carries its slot id.
_SLOT_BYTE = 2
_MIN_BLOB_BYTES = 4
def _hex_bytes(blob):
if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2:
return []
try:
int(blob, 16)
except ValueError:
return []
return hex_pairs(blob.upper())
def is_loaded(blob) -> bool:
"""True when `blob` names an actual program rather than 'none'."""
return _slot_and_loaded(blob)[1]
def slot_of(blob) -> str | None:
"""The slot id `blob` belongs to, or None if it names no program."""
slot, loaded = _slot_and_loaded(blob)
return slot if loaded else None
def _slot_and_loaded(blob) -> tuple[str | None, bool]:
"""Both answers off one parse -- the public pair above needs the same
byte split, and observe() asks for both about the same payload."""
parts = _hex_bytes(blob)
if not parts:
return None, False
if "".join(parts[:2]) == _SENTINEL_PREFIX:
return None, False
return parts[_SLOT_BYTE], True
def advertised_slots(rep) -> list[str]:
"""Slot ids this device says it has downloaded programs in, from its own
CloudExtraCourse_ token. The authority on *which* programs exist -- this
is never inferred from what has been learned so far, so "3 of 9
discovered" is answerable."""
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
if not isinstance(raw, str) or len(raw) % 2:
return []
# Preserve the device's own order (first-seen wins) while dropping any
# repeat, so the flow lists slots the way the appliance does.
return list(dict.fromkeys(hex_pairs(raw.upper())))
def cloud_slots(rep, courses) -> list[str]:
"""Advertised slots that are not already selectable courses.
`CloudExtraCourse_` does not mean the same thing on every family. On the
washers its bytes are opaque payload slots sharing nothing with the
device's own course list, and selecting one needs the full payload. On
the DW5000C dishwasher all four of its bytes *are* course codes in that
device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and
one untranslated), so there it is tagging which of its ordinary courses
came from the cloud. Those are already selectable as plain `Course_`
writes and need nothing from this module.
Subtracting the course list tells the two apart without having to guess
the family: what remains is slots that cannot be selected any other way,
which is exactly the set this module exists for.
"""
known = {c.upper() for c in courses or ()}
return [slot for slot in advertised_slots(rep) if slot not in known]
def supports_cloud_courses(rep, courses) -> bool:
"""True for a device with downloaded programs it cannot otherwise run."""
return bool(cloud_slots(rep, courses))
def loaded_slot(rep) -> str | None:
"""The slot whose payload the appliance currently holds -- the one-time
override when one is set, else the saved default.
Deliberately not gated on the course being Download: the guided setup
flow watches this before the Download course has been confirmed, and
during that walk a change here *is* the signal that the user selected a
different program. A stale token can't produce a false positive because
the flow waits for a change from its own baseline, not for a value.
"""
options = rep.get("x.com.samsung.da.options")
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
option_value(options, DEFAULT_PREFIX)
)
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
"""Restore the persisted record, dropping anything not the shape this
module writes -- it round-trips through the config entry as plain JSON
and a hand-edited .storage file must not be able to crash setup (same
posture as learned._coerce)."""
if not isinstance(stored, dict):
return None, {}
download = stored.get("download_course")
if not isinstance(download, str) or not download:
download = None
slots: dict[str, dict[str, str]] = {}
raw_slots = stored.get("slots")
if isinstance(raw_slots, dict):
for slot, record in raw_slots.items():
if not isinstance(slot, str) or not isinstance(record, dict):
continue
blob = record.get("blob")
if not is_loaded(blob) or slot_of(blob) != slot.upper():
continue
name = record.get("name")
slots[slot.upper()] = {
"blob": blob.upper(),
"name": name if isinstance(name, str) and name.strip() else "",
}
return download, slots
def persist(hass, entry, record: dict) -> None:
"""Write `record` onto the entry. Runs on the event loop, which
async_update_entry requires."""
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record})
class CloudCourses:
"""Per-device store of discovered cloud programs.
Mutated from whichever thread applied the update (the DTLS reader for an
OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply),
so every access takes the lock; persistence is the caller's job, on the
event loop.
"""
def __init__(self, stored_record=None) -> None:
self._lock = threading.Lock()
download, slots = _coerce(stored_record)
self._download_course = download
self._slots = slots
# Course codes seen at the moment a one-time override was *loaded* --
# candidates for "which course means Download on this board", pending
# user confirmation (see download_candidates).
self._candidates: dict[str, int] = {}
# Last one-time payload seen, so a load can be told from a poll that
# merely re-reports one. _UNOBSERVED until the first rep arrives.
self._last_oneshot: object = _UNOBSERVED
# -- learning ---------------------------------------------------------
def observe(self, rep: dict) -> bool:
"""Learn from one applied /course/vs/0 rep; True if anything changed.
Two facts are learnable here. A blob is recorded against the slot its
own byte 2 names, so a program only has to be sitting loaded once --
on either token -- to be replayable forever after.
The Download course code is only ever taken as a *candidate*, and
only at the moment the one-time payload actually *changes* to a
loaded value. That instant is the one the device is known to accept a
program on, so the course selected then is real evidence. Counting
every poll instead would rank by dwell time: tokens in this array are
replaced by prefix and never evicted, so a stale OneTimeCloudCourse_
outlives its run and sits there through however many polls the
appliance spends on some ordinary course afterwards -- which is
exactly the course that would then be suggested. A candidate is still
never applied without confirmation in the options flow, but a
confident wrong suggestion is most of the way to a wrong write, and a
wrong write here starts a real wash cycle.
"""
options = rep.get("x.com.samsung.da.options")
if not options:
return False
known_slots = advertised_slots(rep)
oneshot = option_value(options, ONESHOT_PREFIX)
with self._lock:
# Compared once, at the end, against where this pass started --
# not set per assignment. The two tokens can name the same slot
# with different payloads (a downloaded program with its settings
# tweaked for one run is exactly that shape), and a per-assignment
# flag would then report a change on every single poll forever:
# each pass writes the default's payload and then the one-shot's
# over it, so neither is ever "already stored". Every one of those
# reports rewrites the config entry, which on the SD-card installs
# this integration runs on is the one cost here that really bites.
# The end state is stable (the one-shot is written last and wins),
# so comparing start to end settles after the first pass.
before = {slot: record["blob"] for slot, record in self._slots.items()}
for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
blob = option_value(options, prefix)
slot = slot_of(blob)
# A blob whose slot the device doesn't advertise is not a
# program this appliance offers -- don't record it.
if slot is None or (known_slots and slot not in known_slots):
continue
record = self._slots.get(slot)
if record is None:
self._slots[slot] = {"blob": blob.upper(), "name": ""}
else:
record["blob"] = blob.upper()
changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
course = option_value(options, COURSE_PREFIX)
# Only a transition we actually watched happen counts. On the
# first observation there is nothing to compare against, so a
# payload sitting there is equally consistent with "just loaded"
# and "left over from last week" -- and on a board that doesn't
# clear the token when leaving Download, believing the former
# proposes whatever ordinary course the appliance happens to be
# on. Accepting that prefill would start a real wash cycle.
# (The two dumps in the corpus taken off the Download course both
# show the appliance clearing it to the FFFF sentinel, so this
# may never fire in practice -- which is not a reason to rely on
# it.) Restores don't persist _last_oneshot, so every restart
# re-enters this first-observation state deliberately.
first_ever = self._last_oneshot is _UNOBSERVED
if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot:
self._candidates[course] = self._candidates.get(course, 0) + 1
self._last_oneshot = oneshot
return changed
# -- reads ------------------------------------------------------------
def download_candidates(self) -> list[str]:
"""Course codes seen at the moment a one-time program was loaded,
most-observed first -- what the options flow offers as the likely
Download course. Never used for a write on its own."""
with self._lock:
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
return [code for code, _ in ranked]
def named(self) -> dict[str, str]:
"""Slots that are both learned and named -- the only ones offerable
as a cycle option. An unnamed slot has no label that isn't either
invented or an opaque hex id, so it stays out of the UI until the
user supplies one."""
with self._lock:
return {slot: record["name"] for slot, record in self._slots.items() if record["name"]}
def snapshot(self) -> dict:
with self._lock:
return {
"download_course": self._download_course,
"slots": {slot: dict(record) for slot, record in self._slots.items()},
}
def view(self) -> dict:
"""What the registry sees under FIELD: only what a write or a label
can actually be built from, so a descriptor never has to re-apply
this module's rules."""
with self._lock:
if not self._download_course:
return {}
return {
"download_course": self._download_course,
"programs": {
slot: {"blob": record["blob"], "name": record["name"]}
for slot, record in self._slots.items()
if self._is_usable(record)
},
}
# -- writes -----------------------------------------------------------
def set_download_course(self, code: str | None) -> None:
with self._lock:
self._download_course = code or None
def set_name(self, slot: str, name: str) -> None:
with self._lock:
record = self._slots.get(slot.upper())
if record is not None:
record["name"] = name.strip()
@staticmethod
def _is_usable(record) -> bool:
"""A slot is offerable once it has a name. The device supplies the
payload; only the user can supply the label, so this is the whole
rule and it is stated once."""
return bool(record["name"])
def undiscovered(rep: dict, record: dict, courses) -> list[str]:
"""Cloud slots that aren't yet usable -- unlearned or unnamed. What the
Repairs issue counts, and what the options flow asks the user to walk the
appliance through. Counts against cloud_slots, not every advertised byte:
a slot that is already a selectable course is nothing to set up."""
programs = record.get("slots") or {}
return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")]
+407 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import contextlib
import datetime
import errno
@@ -34,6 +35,7 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from . import cloudcourse
from .const import (
CLIENTHELLO_PROBE_RETRIES,
CLIENTHELLO_PROBE_TIMEOUT_S,
@@ -62,6 +64,8 @@ from .const import (
)
from .learned import persist as learned_persist
from .learned import stored as learned_stored
from .registry.capabilities.laundry import cycle_options, personal_course_labels
from .registry.subdevices import MAIN
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
@@ -74,6 +78,14 @@ _HYSTERESIS_MINUTES = NumberSelector(
)
)
# Guided download-cycle setup: how long a round waits for the user to
# select a program, and how often it live-reads /course/vs/0 while doing
# so. The read takes the session lock, so the interval is a few seconds
# rather than sub-second -- fast enough to feel immediate to someone
# standing at the appliance, slow enough not to starve polling.
_CLOUD_WAIT_TIMEOUT_S = 180.0
_CLOUD_PROBE_INTERVAL_S = 3.0
_LOGGER = logging.getLogger(__name__)
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
@@ -827,15 +839,27 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
def __init__(self) -> None:
self._debug_href: str = ""
self._debug_result: tuple[int, dict] | None = None
# Guided download-cycle setup. `_cloud_task` is created once per
# round and reused across re-entries (Home Assistant re-enters a
# progress step while its spinner is up).
self._cloud_task: asyncio.Task[str | None] | None = None
self._cloud_slot: str | None = None
self._cloud_baseline: str | None = None
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "forget_learned_modes", "debug_write"],
)
menu = ["settings", "forget_learned_modes", "debug_write"]
# Only offered on an appliance that actually advertises downloaded
# programs (issue #342) -- every other device would get a menu entry
# leading to an empty screen.
coord = self._coordinator()
if coord is not None and cloudcourse.supports_cloud_courses(
coord.cloud_course_rep(), cycle_options(coord.canonical_resources(MAIN))
):
menu.insert(1, "cloud_courses")
return self.async_show_menu(step_id="init", menu_options=menu)
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
@@ -901,6 +925,385 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
description_placeholders={"codes": ", ".join(codes) if codes else "(none)"},
)
async def async_step_cloud_courses(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Entry point for download-cycle setup (issue #342).
Guided setup is offered first because it is the only version of this
that a first-time user can complete confidently: it asks about a
program in the moment they select it, rather than about a list of hex
ids some time later. The bulk form stays for renaming afterwards,
which guided setup is bad at.
"""
return self.async_show_menu(
step_id="cloud_courses",
menu_options=["cloud_guided", "cloud_manual"],
)
@callback
def async_remove(self) -> None:
"""Stop probing when the flow goes away.
Closing the dialog is the documented way to leave guided setup, so it
has to actually stop: an abandoned round would otherwise go on
live-reading /course/vs/0 every few seconds until its timeout, taking
the session lock each time, for a user who has walked away.
"""
if self._cloud_task is not None and not self._cloud_task.done():
self._cloud_task.cancel()
self._cloud_task = None
async def async_step_cloud_guided(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Start (or restart) a guided discovery round."""
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
self._cloud_task = None
self._cloud_slot = None
# Baseline: whatever is loaded right now. The round completes when
# the appliance moves off it, so the program the user has *already*
# selected can't immediately re-trigger and loop the flow.
self._cloud_baseline = cloudcourse.loaded_slot(coord.cloud_course_rep())
return await self.async_step_cloud_wait()
async def async_step_cloud_wait(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Wait for the user to select a different downloaded program.
The task is created once and reused across re-entries -- Home
Assistant polls this step while the spinner is up, and building a
fresh task each time would restart the wait forever.
"""
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
if self._cloud_task is None:
self._cloud_task = self.hass.async_create_task(
self._await_cloud_selection(coord), eager_start=False
)
if not self._cloud_task.done():
return self.async_show_progress(
step_id="cloud_wait",
progress_action="cloud_wait",
progress_task=self._cloud_task,
description_placeholders=self._cloud_progress_placeholders(coord),
)
self._cloud_slot = self._cloud_task.result()
self._cloud_task = None
if self._cloud_slot is None:
return self.async_show_progress_done(next_step_id="cloud_timeout")
return self.async_show_progress_done(next_step_id="cloud_name")
async def _await_cloud_selection(self, coord) -> str | None:
"""Poll until the loaded program changes; None on timeout."""
deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S
while time.monotonic() < deadline:
slot = await coord.async_probe_cloud_courses()
# Only a slot the store actually recorded. The probe reports
# whatever payload is loaded, while observe() declines one whose
# slot the device doesn't advertise -- offering to name that would
# take a name and silently discard it, since there is no record to
# hang it on and no payload to replay.
if (
slot is not None
and slot != self._cloud_baseline
and coord.cloud_courses.snapshot()["slots"].get(slot)
):
return slot
await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S)
return None
def _cloud_progress_placeholders(self, coord) -> dict[str, str]:
"""Counts plus the names assigned so far.
Listing them is what makes a nine-program walk followable -- it is
the only orientation available, since the programs still to do are
unnamed by definition. Shown while naming too, where it doubles as
duplicate avoidance: the form rejects a repeated name, so seeing the
others first beats being bounced.
In the appliance's own advertised order, which is at least a stable
order, without numbering them -- whether that order matches the dial
is plausible but unverified, and implying it would be worse than
saying nothing.
"""
rep = coord.cloud_course_rep()
courses = cycle_options(coord.canonical_resources(MAIN))
record = coord.cloud_courses.snapshot()
slots = cloudcourse.cloud_slots(rep, courses)
names = [n for s in slots if (n := (record["slots"].get(s) or {}).get("name"))]
return {
"named": str(len(names)),
"total": str(len(slots)),
"named_list": ", ".join(names) if names else "none yet",
}
async def async_step_cloud_name(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Name the program the user just selected.
Persists immediately rather than batching to the end of the flow, so
closing the dialog at any point is a clean "save and exit" -- there is
no pending work to lose, and reopening resumes from the store.
"""
coord = self._coordinator()
if coord is None or self._cloud_slot is None:
return self.async_abort(reason="not_loaded")
slot = self._cloud_slot
existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "")
if user_input is not None:
# Rebuilt into the shared validator's shape. `download_course`
# is forwarded only when this form actually carried it, so its
# absence still means "not asked about" rather than "clear it".
payload: dict[str, Any] = {f"name_{slot}": str(user_input.get("name", "")).strip()}
if "download_course" in user_input:
payload["download_course"] = user_input["download_course"]
errors = self._apply_cloud_course_names(coord, [slot], payload)
if errors:
return self._cloud_name_form(coord, slot, existing, errors=errors)
# Straight back to waiting: the appliance is still sitting on this
# program, and the next round baselines on it, so there is nothing
# to click through.
self._cloud_baseline = slot
return await self.async_step_cloud_wait()
return self._cloud_name_form(coord, slot, existing)
def _cloud_name_form(
self, coord, slot: str, existing: str, errors: dict[str, str] | None = None
) -> ConfigFlowResult:
"""One text field, with the copy switched on whether this program is
already set up. Re-selecting one is not an error -- it is how someone
checks their work -- so it gets an edit form rather than a rejection,
and the counter deliberately does not move.
The Download course joins the form the first time round, and only
until it is confirmed. Guided setup would otherwise finish having
collected names but no course, and a program needs both before it can
be offered -- so the whole walk would produce nothing selectable. It
is asked here rather than up front because this is the first moment
there is evidence to prefill: the user has just loaded a program, so
the course showing alongside it is the Download one.
"""
placeholders = self._cloud_progress_placeholders(coord)
placeholders["slot"] = slot
placeholders["remaining"] = (
coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--"
)
fields: dict[Any, Any] = {vol.Optional("name", default=existing): _TEXT}
if not coord.cloud_courses.snapshot()["download_course"]:
fields[
vol.Optional("download_course", description={"suggested_value": self._cloud_course})
] = self._cloud_course_selector(coord)
return self.async_show_form(
step_id="cloud_name",
data_schema=vol.Schema(fields),
errors=errors or {},
description_placeholders=placeholders,
last_step=False,
)
@property
def _cloud_course(self) -> str | None:
"""The best observed candidate, narrowed to what the selector offers.
Unfiltered, a candidate the appliance's own course list no longer
contains would prefill a dropdown that rejects it, and the form would
fail validation on a value the user never chose.
"""
coord = self._coordinator()
if coord is None:
return None
available = cycle_options(coord.canonical_resources(MAIN))
return next((c for c in coord.cloud_courses.download_candidates() if c in available), None)
def _cloud_course_selector(self, coord):
"""The appliance's own course codes, observed candidates first.
custom_value stays off deliberately: whatever lands here becomes the
Course_ token of a real write, and a typed-in code the appliance
doesn't offer would start something nobody chose.
"""
available = cycle_options(coord.canonical_resources(MAIN))
candidates = [c for c in coord.cloud_courses.download_candidates() if c in available]
ordered = candidates + [c for c in available if c not in candidates]
return SelectSelector(
SelectSelectorConfig(
options=ordered,
custom_value=False,
mode=SelectSelectorMode.DROPDOWN,
)
)
async def async_step_cloud_timeout(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Nothing was selected in time. Offer another round rather than
dropping the user out of the flow -- and an explicit finish, for
anyone who doesn't think to close the dialog."""
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
return self.async_show_menu(
step_id="cloud_timeout",
menu_options=["cloud_guided", "cloud_finish"],
description_placeholders=self._cloud_progress_placeholders(coord),
)
async def async_step_cloud_finish(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
return self.async_create_entry(data=dict(self.config_entry.options))
async def async_step_cloud_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Name the cloud "Download" programs this appliance has (issue #342).
The device advertises how many downloaded programs it holds but never
what any of them is called, and only ever exposes the replay payload
for the one currently loaded. So this screen can only offer the ones
already seen loaded, and asks the user for the names -- the appliance
has no name to give and inventing one is not an option (the same rule
that governs unrecognized local course codes).
Also confirms which course code means Download. It is auto-detected
by observation, but never used for a write until confirmed here: the
options array replaces tokens by prefix and never evicts them, so a
stale program token can be reported alongside an unrelated course and
make an ordinary wash cycle look like the Download one. Writing the
wrong code would start the wrong cycle.
"""
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
store = coord.cloud_courses
rep = coord.cloud_course_rep()
record = store.snapshot()
slots = record["slots"]
advertised = cloudcourse.cloud_slots(rep, cycle_options(coord.canonical_resources(MAIN)))
# Learned slots keep the appliance's own ordering; anything learned
# but no longer advertised still gets a row so a name isn't stranded.
known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised]
if user_input is not None:
errors = self._apply_cloud_course_names(coord, known, user_input)
if not errors:
return self.async_create_entry(data=dict(self.config_entry.options))
return self._cloud_courses_form(coord, known, advertised, errors=errors)
return self._cloud_courses_form(coord, known, advertised)
def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]:
"""Validate and store the submitted names + Download course code.
The select maps a chosen label back to a raw value by matching display
text, so two options sharing a label resolve to whichever comes first.
Two sources of collision are checkable here and both are rejected:
the user's own names against each other, and against the appliance's
personal-course labels, which the device reports verbatim and the
select renders as-is.
A collision with a *translated* local course name is deliberately not
checked. The catalog this process can read is English (catalog.py),
while what the user actually sees is localized in the frontend -- so
checking it would reject "Cotton" for a German user whose dropdown
says "Baumwolle", and still miss the real collision when they type
"Baumwolle". Wrong in both directions outside one locale, against an
outcome the option ordering already makes deterministic (local
courses come first, so a shared label resolves to the real cycle).
"""
names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known}
stored_slots = coord.cloud_courses.snapshot()["slots"]
taken = {name.casefold() for name in self._device_course_names(coord)}
# Programs this form isn't editing. The bulk form edits every slot at
# once so this adds nothing there, but guided setup submits one at a
# time -- without it, naming two programs the same was accepted, and
# the select resolves a shared label to whichever option comes first,
# so picking the second would run the first one's payload.
taken |= {
record["name"].casefold()
for slot, record in stored_slots.items()
if record["name"] and slot not in known
}
for name in names.values():
if not name:
continue
if name.casefold() in taken:
return {"base": "cloud_course_name_duplicate"}
taken.add(name.casefold())
# Absent means "this form didn't ask" -- the guided name form drops
# the field once the course is confirmed -- which must leave the
# stored value alone rather than clearing it. A program is only
# offerable when both a name and the course are set, so clearing it
# here would make naming things remove them from the cycle list.
if "download_course" not in user_input:
coord.apply_cloud_courses(names)
return {}
# Belt and braces over the selector's own custom_value=False: this
# value becomes the Course_ token of a real write, so it is checked
# against the appliance's own course list here too, where the store
# is actually updated.
course = user_input.get("download_course") or None
if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)):
return {"base": "cloud_course_unknown_course"}
coord.apply_cloud_courses(names, course)
return {}
def _device_course_names(self, coord) -> set[str]:
"""Course names this appliance reports itself.
Only the personal-course labels: the device sends these as text and
the select renders them unchanged, so they are the same string in
every locale and can be compared against safely. See the caller for
why translated course names are not included.
"""
resources = coord.canonical_resources(MAIN)
personal = personal_course_labels(resources)
return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))}
def _cloud_courses_form(
self, coord, known, advertised, errors: dict[str, str] | None = None
) -> ConfigFlowResult:
store = coord.cloud_courses
record = store.snapshot()
slots = record["slots"]
fields: dict[Any, Any] = {}
for slot in known:
fields[vol.Optional(f"name_{slot}", default=slots.get(slot, {}).get("name", ""))] = (
_TEXT
)
suggested = record["download_course"] or self._cloud_course
fields[vol.Optional("download_course", description={"suggested_value": suggested})] = (
self._cloud_course_selector(coord)
)
pending = [s for s in advertised if s not in slots]
return self.async_show_form(
step_id="cloud_manual",
data_schema=vol.Schema(fields),
errors=errors or {},
description_placeholders={
"found": str(len(known)),
"total": str(len(advertised)) if advertised else str(len(known)),
"pending": ", ".join(pending) if pending else "(none)",
},
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
+9
View File
@@ -48,6 +48,15 @@ CONF_LEARNED_MODES = "learned_modes"
CONF_LEARN_MODES = "learn_device_modes"
DEFAULT_LEARN_MODES = True
# entry.data key: cloud "Download" programs discovered on a laundry device
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
# replay payload is only ever visible while the device happens to be sitting
# on it, so it has to survive a restart -- but a richer shape, because a
# cloud program also needs a user-supplied name and the device's own
# Download course code. See cloudcourse.py, which owns the shape. Shape:
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
CONF_CLOUD_COURSES = "cloud_courses"
# Options-flow key (entry.options, not entry.data): lets a user override
# the device-wide remote-control-off write block for a specific device
# (issue #54). Some devices accept certain writes even while reporting
+221 -8
View File
@@ -10,7 +10,7 @@ import threading
import time
import zlib
from datetime import timedelta
from typing import Any
from typing import Any, cast
import cbor2
from homeassistant.config_entries import ConfigEntry
@@ -22,8 +22,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from . import cloudcourse
from .cloudcourse import CloudCourses
from .cloudcourse import persist as cloud_persist
from .const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_CLOUD_COURSES,
CONF_DEVICE_TYPE,
CONF_HOST,
CONF_LEAF_CERT_PEM,
@@ -52,6 +56,7 @@ from .registry.capabilities.common import (
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.capabilities.laundry import cycle_options
from .registry.discovery import BoundEntity
from .registry.entities import ClimateDesc
from .registry.identity import (
@@ -62,6 +67,7 @@ from .registry.identity import (
resolve_serial,
)
from .registry.subdevices import (
MAIN,
Subdevice,
canonical_view,
discover_partitioned,
@@ -69,6 +75,10 @@ from .registry.subdevices import (
normalize_seed_batch,
)
# Sentinel for apply_cloud_courses: "leave this field as it is",
# distinct from None which means "clear it".
_KEEP = object()
_LOGGER = logging.getLogger(__name__)
_SEED_PATH = ["device", "0"]
@@ -259,6 +269,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# supported (issue #327), restored from the entry so one learned
# last week is still offered today. See learned.py.
self._learned = LearnedModes(entry.data.get(CONF_LEARNED_MODES))
# Cloud "Download" programs discovered on this device (issue #342).
# Same restore-from-entry shape as _learned above; see cloudcourse.py.
self._cloud = CloudCourses(entry.data.get(CONF_CLOUD_COURSES))
# Narrowed to this device's own climate hrefs once discovery has
# run -- see _refresh_learnable_hrefs.
self._learnable_hrefs: set[str] = set()
@@ -312,6 +325,61 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
which copies every tracked href to build the snapshot dict."""
return self._cache.get(href) or {}
def entity_resources(self) -> dict[str, dict]:
"""The live snapshot as entity descriptors should see it: the device's
own reps, plus this integration's discovered cloud programs merged
onto /course/vs/0 under cloudcourse.FIELD (issue #342).
Merged at read time rather than applied to the state cache, so the
synthetic field can never be polled over or written to the device --
`last_resources` stays exactly what the appliance reported. It rides
on the rep instead of a resource of its own because rep_fn receives
only its own href's rep: a sibling href would be invisible to it, and
/course/vs/0 is the one resource every consumer of this data is
already bound to.
MAIN only, by construction: cloudcourse.COURSE_HREF is a canonical
href and this snapshot is keyed by actual ones, so a composite
appliance's second course resource (/<uuid>/course/vs/0 on the
one-body washer-dryer) is not merged and not learned from. No device
seen so far advertises cloud programs on anything but MAIN; making
this per-subdevice means keying the store by actual href the way
LearnedModes does, and migrating the persisted shape.
"""
snapshot = self.last_resources
rep = snapshot.get(cloudcourse.COURSE_HREF)
if rep is None:
return snapshot
view = self._cloud.view()
if not view:
return snapshot
snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view}
return snapshot
def entity_rep(self, href: str) -> dict:
"""One href's rep as descriptors see it -- `resource()` plus the
merge `entity_resources` would have applied. Exists so the write path
doesn't copy every tracked href to read one rep, which is the very
thing `resource()` was added to avoid."""
rep = self.resource(href)
if href != cloudcourse.COURSE_HREF or not rep:
return rep
view = self._cloud.view()
return {**rep, cloudcourse.FIELD: view} if view else rep
def device_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s canonical view of exactly what the appliance
reported -- no integration state merged in.
The counterpart to canonical_resources for everything that *exports*
resources rather than rendering entities from them: diagnostics and
the debug read service. Keeping this a separate call rather than
filtering the merged view downstream is what makes "a dump is what the
device said" a property of which method you call, instead of a
convention every future exporter has to remember.
"""
return canonical_view(subdevice, self.last_resources, self.subdevices)
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s view of the live snapshot, rewritten to canonical
hrefs (issue #177, see subdevices.canonical_view). Any platform
@@ -325,7 +393,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
cached = self._canonical_cache.get(view_key)
if cached is not None:
return cached
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
view = canonical_view(subdevice, self.entity_resources(), self.subdevices)
self._canonical_cache[view_key] = view
return view
@@ -389,7 +457,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
An 'optimistic' rep is the value this integration just wrote, not
one the device reported, so there is nothing to learn from it."""
if source == "optimistic" or href not in self._learnable_hrefs:
if source == "optimistic":
return
if href == cloudcourse.COURSE_HREF:
self._observe_cloud_courses(rep)
if href not in self._learnable_hrefs:
return
if not self.learning_enabled:
return
@@ -406,6 +478,134 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def _persist_learned(self) -> None:
persist(self.hass, self._entry, self._learned.snapshot())
# ------------------------------------------------------------------
# Cloud "Download" programs (issue #342) -- see cloudcourse.py
# ------------------------------------------------------------------
def _observe_cloud_courses(self, rep: dict) -> None:
"""Learn a downloaded program's replay payload from one applied
/course/vs/0 rep. Runs on whichever thread applied the update, so
both the persist and the Repairs refresh go through hass.add_job.
Unlike learned modes this has no opt-out option: it records only
what the appliance itself reports about programs it itself
advertises, and nothing is offered in the UI until the user names it.
"""
if not self._cloud.observe(rep):
return
self.hass.add_job(self._persist_cloud_courses)
@callback
def _persist_cloud_courses(self) -> None:
cloud_persist(self.hass, self._entry, self._cloud.snapshot())
# A newly learned program changes what entity_resources() hands out.
self._canonical_cache.clear()
self._refresh_cloud_course_issue()
@property
def cloud_courses(self) -> CloudCourses:
"""The discovered-program store, for reads (snapshot/view/candidates).
Mutations go through apply_cloud_courses below, not through this --
the store itself doesn't persist or invalidate, and `_canonical_cache`
now depends on its contents, so a caller that mutates it directly
leaves entity options stale with no error to say so.
"""
return self._cloud
def cloud_course_rep(self) -> dict:
"""/course/vs/0's live rep -- what advertises the slot list."""
return self.resource(cloudcourse.COURSE_HREF)
async def async_probe_cloud_courses(self) -> str | None:
"""Live-read /course/vs/0, apply it, and report which slot is loaded.
/course/vs/0 is cold-tier, so passively a program selection can take
a whole poll interval to show up -- far too slow for the guided setup
flow, which is a person standing at the appliance waiting for Home
Assistant to notice. The read goes through the normal apply path, so
learning and persistence still happen in exactly one place.
Returns None when the read fails; the caller is a retry loop and a
single missed read is not worth surfacing.
"""
try:
code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF)
except Exception:
# One missed probe; the caller is a retry loop.
self._log.debug("cloud-course probe failed", exc_info=True)
return None
if not _coap_accepted(code) or not rep:
return None
self._observe.apply(cloudcourse.COURSE_HREF, rep, source="poll")
return cloudcourse.loaded_slot(rep)
def apply_cloud_courses(self, names: dict[str, str], download_course: object = _KEEP) -> None:
"""The one mutation path for the cloud-program store (issue #342).
Takes the whole submission at once so a nine-program naming pass is
one config-entry write rather than nine, and so persistence, the
canonical-view invalidation and the Repairs refresh can't be done for
one half of a change and skipped for the other.
`download_course` defaults to "leave it alone" rather than None.
Guided setup submits one name at a time and says nothing about the
course; with None as the default that silently cleared a course the
user had already confirmed, and since a program is only offerable
once both are set, naming things made them disappear.
"""
for slot, name in names.items():
self._cloud.set_name(slot, name)
if download_course is not _KEEP:
self._cloud.set_download_course(cast("str | None", download_course))
self._persist_cloud_courses()
@callback
def _refresh_cloud_course_issue(self) -> None:
"""Raise or clear the "you have downloaded programs Home Assistant
can't offer yet" Repairs issue (issue #342).
A downloaded program is only usable once the appliance has been seen
sitting on it (that is the only time its replay payload is visible)
and the user has given it a name. The device advertises how many it
has, so the gap between that and what's usable is knowable -- and
it can only be closed by the user walking the appliance through its
own Download list, which is exactly what a Repair is for.
Gated on having seen at least one payload, which is the only
available evidence that this household uses downloaded programs at
all. Without it, an appliance that merely advertises slots -- the
DW5000C fixture reports four and has never loaded any -- would carry
a permanent warning about a feature its owner may never touch, and
nothing they do in Home Assistant could clear it. Running one
downloaded program is what turns the nudge on, and naming them is
what turns it off.
"""
issue_id = f"cloud_courses_{self._entry.entry_id}"
rep = self.cloud_course_rep()
record = self._cloud.snapshot()
courses = cycle_options(self.canonical_resources(MAIN))
pending = cloudcourse.undiscovered(rep, record, courses)
slots = cloudcourse.cloud_slots(rep, courses)
needs_course = bool(slots) and not record["download_course"]
if record["slots"] and (pending or needs_course):
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="cloud_courses_undiscovered",
translation_placeholders={
"device_name": self.device_info.get("name") or "This appliance",
"pending": str(len(pending)),
"total": str(len(slots)),
},
learn_more_url=DEVICE_SUPPORT_ISSUE_URL,
)
else:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice on this connection (issue
#177): the master's own device_info for MAIN, or a linked child
@@ -508,7 +708,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
with self._push_pending_lock:
self._push_pending = False
if self.bound:
self.async_set_updated_data(flatten(self.bound, self._cache.snapshot()))
self.async_set_updated_data(flatten(self.bound, self.entity_resources()))
def _poll_once(self) -> dict[str, dict]:
"""GET /device/0, return parsed resources. Blocking.
@@ -1070,7 +1270,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
type(e).__name__,
e,
)
return flatten(self.bound, self._cache.snapshot())
return flatten(self.bound, self.entity_resources())
self._consecutive_poll_timeouts = 0
# A lone reconnect is routine (README's "Known device
# behavior"); only warn once they pile up. Pause first so
@@ -1099,7 +1299,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# there are bound entities to carry it.
if self._discovered and snapshot:
self._log.debug("Full error:", exc_info=e2)
return flatten(self.bound, snapshot)
return flatten(self.bound, self.entity_resources())
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
else:
# A fresh session has zero OBSERVE registrations; if we
@@ -1147,6 +1347,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for href, rep in resources.items():
self._observe.apply(href, rep, source=source)
if first_cycle:
# The apply loop above has just fed this poll's /course/vs/0 to
# the cloud-program store, so the gap is knowable now. Explicit
# rather than left to _persist_cloud_courses: a device whose
# programs are all already named learns nothing on this cycle and
# would otherwise never get its stale Repair cleared.
self._refresh_cloud_course_issue()
if first_cycle or self._resubscribe_due:
self._resubscribe_due = False
await self._attempt_observe_mode()
@@ -1163,7 +1371,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
)
return flatten(self.bound, self._cache.snapshot())
return flatten(self.bound, self.entity_resources())
# ------------------------------------------------------------------
# Command dispatch (called by entity platforms in Task 5)
@@ -1185,7 +1393,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or "") or {}
# Through entity_rep(), not the bare cache: write_fn must see the
# same rep exists_fn/rep_fn were handed, including the merged
# cloud-program field (issue #342). Identical to the cache entry for
# every href that field doesn't touch, and without copying the whole
# tree to read one rep.
rep = self.entity_rep(href or "")
# The remote-control gate below keys off the raw on-the-wire href
# and a raw snapshot -- /remotectrl/* is a shared, MAIN-only
# resource that a subdevice's canonical_resources() view (owned
+24 -2
View File
@@ -16,8 +16,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.loader import async_get_integration
from . import cloudcourse
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.capabilities.laundry import cycle_options
from .registry.redact import redact_resources
from .registry.subdevices import MAIN
@@ -32,6 +34,7 @@ async def async_get_config_entry_diagnostics(
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
cloud_courses = coordinator.cloud_courses.snapshot()
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
@@ -54,7 +57,7 @@ async def async_get_config_entry_diagnostics(
# than redacting /information/vs/0 again -- modelNum never matches
# redact.py's substring rules, so the value is the same either way.
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
res = redact_resources(coordinator.device_resources(su))
return {
"kind": su.kind,
"key": su.key,
@@ -88,7 +91,7 @@ async def async_get_config_entry_diagnostics(
# /mode/vs/0 under no attribution. Each sibling reports its own
# resources in `subdevices` below instead. For a device with no
# subdevices, this is byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
"resources": redact_resources(coordinator.device_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# from) is deliberately NOT redacted here, unlike elsewhere in
@@ -137,6 +140,25 @@ async def async_get_config_entry_diagnostics(
"enabled": coordinator.learning_enabled,
"codes": coordinator.learned_snapshot(),
},
# Cloud "Download" programs discovered on this device (issue #342),
# reported separately from `resources` for the same reason as
# learned_modes above -- the dump there stays exactly what the device
# said, and this is what the integration made of it.
#
# Reported in full, names included. Half of what can go wrong with
# this feature is a configuration question -- which programs got
# named, which Download course was confirmed, whether a payload was
# ever captured for a slot the device advertises -- and none of that
# is answerable from the payloads alone. The names are the user's own
# words, so this is the one place they appear; they reach a dump only
# because its owner chose to download and share it.
"cloud_courses": {
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
"cloud_slots": cloudcourse.cloud_slots(
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
),
**cloud_courses,
},
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
@@ -136,6 +136,28 @@ def _active_alarm_codes(items):
return ", ".join(codes) if codes else "none"
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
def option_value(options, prefix):
"""Find `<prefix>_<value>` in an options[] array and return <value>.
Lives here rather than in laundry.py, which is where it grew, because
cloudcourse.py needs it too and laundry.py imports *that* -- so the
reverse import would be a module cycle. The coordinator already imports
from this module, so nothing about the dependency direction is unusual;
it is specifically the laundry/cloudcourse pair that can't reach each
other. Anchored at position 0 so 'Course_' never matches
'CloudCourse_'/'OneTimeCloudCourse_'.
"""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
def merge_options_field(cached, new_tokens):
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
x.com.samsung.da.options[]-style array the same way the device itself
@@ -23,9 +23,11 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
from datetime import UTC, datetime
from datetime import time as dt_time
from ... import cloudcourse
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
from .common import hex_pairs, option_value
_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
@@ -198,11 +200,6 @@ BUZZER_SOUND = Capability(
# boards expose the same /course/vs/0 options contract.
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or "_" not in raw:
@@ -218,14 +215,6 @@ def cycle_options(resources):
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
@@ -309,20 +298,124 @@ def _course_codes_from_supported_options(course_rep):
return []
def option_tokens(*pairs):
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
form of option_write, for the one write that needs two tokens to land in
the same options[] array together (see cycle_write's cloud branch)."""
return [f"{prefix}_{value}" for prefix, value in pairs]
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above for why this doesn't read/rewrite the whole array."""
return [f"{prefix}_{new_value}"]
return option_tokens((prefix, new_value))
# ---------------------------------------------------------------------------
# Cloud "Download" programs, folded into this same cycle select (issue #342).
#
# A device that has downloaded programs advertises them on the same
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
# learned store, and the reasoning for all of it. Everything below is just
# how that store reaches the select: the coordinator merges it onto this
# href's rep under cloudcourse.FIELD, so the option list, current value,
# label, and write path each read it from the rep or snapshot they already
# receive.
#
# They ride in the cycle select rather than a select of their own because
# that is what they are to a user -- on the appliance's own controls,
# "Download" occupies one position among the ordinary courses, and picking a
# downloaded program is picking a cycle. Their raw values are namespaced
# ('cloud:<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.
# Hence the two-token write, the only one in this module.
def _cloud_state(rep):
return rep.get(cloudcourse.FIELD) or {}
def cloud_options(rep):
"""Namespaced raw values for every named, learned cloud program."""
return [
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
]
def cloud_label(value, resources):
"""The user's own name for a 'cloud:<slot>' value.
Cloud program names are user-supplied, never translated: the appliance
reports only an opaque slot id, and inventing an English label for one
is exactly what this module refuses to do for unrecognized local course
codes (see washer_cycle_fallback).
"""
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
return None
slot = value[len(cloudcourse.RAW_PREFIX) :]
rep = resources.get(cloudcourse.COURSE_HREF) or {}
program = _cloud_state(rep).get("programs", {}).get(slot)
return program["name"] if program else None
def cloud_current(rep):
"""'cloud:<slot>' when a named cloud program is the live selection.
Gated on the course actually being this device's confirmed Download
course: tokens in this array are replaced by prefix and never evicted, so
a one-time program token outlives the run it belonged to and would
otherwise report "Jeans" while an ordinary cotton cycle runs.
"""
state = _cloud_state(rep)
download = state.get("download_course")
options = rep.get("x.com.samsung.da.options")
if not download or option_value(options, "Course") != download:
return None
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
slot = cloudcourse.slot_of(blob)
if slot is None:
# No one-time override loaded: the appliance falls back to whatever
# the persisted default holds (confirmed with the issue #342
# reporter -- leaving Download and returning to it re-selects the
# saved program, not the last one-time one).
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
if slot is None or slot not in state.get("programs", {}):
return None
return f"{cloudcourse.RAW_PREFIX}{slot}"
def cycle_write(p, rep, href=None):
if not rep.get("x.com.samsung.da.options"):
return None
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
return _cloud_cycle_write(p, rep)
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
}
def _cloud_cycle_write(p, rep):
state = _cloud_state(rep)
download = state.get("download_course")
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
if not download or program is None:
return None
# Order matches what the appliance was confirmed to accept.
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_tokens(
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
),
}
def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
"""Return device-provided personal course names keyed by course code.
@@ -399,6 +492,11 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
Left at its default for dishwasher, which has no equivalent table-id
resource and no evidence its codes vary by table the way washer/
dryer's do.
Any cloud "Download" programs the user has discovered and named join the
same option list, after the local courses -- see the cloud section above.
A device with none (or one whose owner hasn't named any yet) gets exactly
the list it got before they existed.
"""
key = translation_key
if table_href is not None:
@@ -410,14 +508,31 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
candidate = f"{translation_key}_{table.lower()}"
return candidate if has_entity_translation("select", candidate) else "cycle"
def options(resources):
rep = resources.get(cloudcourse.COURSE_HREF) or {}
# Local courses first: a user-supplied cloud name that happens to
# match a translated course name resolves back to the real local
# course on write, which is the safer of the two. The options flow
# rejects such a name outright, so this is a backstop, not the fix.
return [*cycle_options(resources), *cloud_options(rep)]
def current(rep):
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")
def label(value, resources):
cloud = cloud_label(value, resources)
if cloud is not None:
return cloud
return display_fn(value, resources) if display_fn is not None else None
return SelectDesc(
key="cycle",
icon=icon,
translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
display_fn=display_fn,
options=options,
exists_fn=lambda rep, resources: bool(options(resources)),
rep_fn=current,
display_fn=label,
write_fn=cycle_write,
)
+15 -16
View File
@@ -67,22 +67,21 @@ def _display(value, translation_key: str | None, fallback_fn=None):
"""
if not isinstance(value, str):
return value
if translation_key:
known = translated_states("select", translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Give an explicit
# device-specific fallback the opportunity to make an opaque value
# readable; otherwise the raw device value remains the best choice.
if fallback_fn is None:
return value
elif translated := _translation_state(value, known):
return translated
if fallback_fn is not None:
fallback = fallback_fn(value)
if fallback is not None:
return fallback
known = translated_states("select", translation_key) if translation_key else frozenset()
if translated := _translation_state(value, known):
return translated
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
return fallback
if translation_key and not known:
# No state table for this key: either the entity isn't translated at
# all, or its name is translated but its options deliberately aren't
# (an unrecognized course table, say). Nothing named this value, so
# the raw device value is the best choice -- the cosmetic reshaping
# below would only mangle an opaque code, turning a course '0E' into
# '0 E'. Reached only when the fallback *declined* the value, not
# merely when none was supplied: cycle_select always supplies one now
# (it labels cloud programs) and returns None for everything else.
return value
if value.islower():
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
+4 -1
View File
@@ -170,7 +170,10 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
# href: lets a user enumerate what exists without hammering the
# device (see this module's docstring and the coordinator's
# canonical_resources).
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
# device_resources, not canonical_resources: this response is what
# the appliance reported, without the fields this integration merges
# on for its own use (see coordinator.entity_resources).
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
return cast(ServiceResponse, snapshot)
# Same normalize-before-translate order as the write path above.
@@ -1406,6 +1406,7 @@
"title": "Možnosti LocalThings",
"menu_options": {
"settings": "Nastavení zápisu pro dálkové ovládání",
"cloud_courses": "Stažené cykly",
"forget_learned_modes": "Zapomenout zapamatované režimy",
"debug_write": "Ladění: zápis do prostředku"
}
@@ -1444,20 +1445,63 @@
"debug_write": "Zapsat do dalšího prostředku",
"finish": "Dokončit"
}
},
"cloud_manual": {
"title": "Stažené cykly",
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
"data": {
"download_course": "Kód programu „Stažený program“"
}
},
"cloud_courses": {
"title": "Stažené cykly",
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
"menu_options": {
"cloud_guided": "Průvodce nastavením",
"cloud_manual": "Upravit názvy"
}
},
"cloud_wait": {
"title": "Stažené cykly"
},
"cloud_name": {
"title": "Pojmenujte tento cyklus",
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
"data": {
"name": "Název",
"download_course": "Kód programu „Stažený program“"
}
},
"cloud_timeout": {
"title": "Nebyl vybrán žádný cyklus",
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
"menu_options": {
"cloud_guided": "Počkat znovu",
"cloud_finish": "Dokončit"
}
}
},
"error": {
"empty_payload": "Zadejte alespoň jedno pole k zápisu.",
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant."
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant.",
"cloud_course_name_duplicate": "Dva cykly mají stejný název. Názvy musí být jedinečné.",
"cloud_course_unknown_course": "Tento program zařízení nenabízí. Vyberte jej ze seznamu."
},
"abort": {
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
},
"progress": {
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
}
},
"issues": {
"device_gap": {
"title": "Neúplné pokrytí funkcí pro {device_name}",
"description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue."
},
"cloud_courses_undiscovered": {
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
}
},
"exceptions": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings-Optionen",
"menu_options": {
"settings": "Geräteeinstellungen",
"cloud_courses": "Download-Programme",
"debug_write": "Debug: In eine Ressource schreiben",
"forget_learned_modes": "Gemerkte Modi vergessen"
}
@@ -1444,20 +1445,63 @@
"forget_learned_modes": {
"title": "Gemerkte Modi vergessen",
"description": "Aktuell gemerkt: {codes}\n\nDies sind Modi, in denen sich dieses Gerät selbst gemeldet hat, ohne sie als unterstützt anzugeben; sie werden aufbewahrt, damit sie auswählbar bleiben. Das Vergessen ist die Lösung, wenn sich einer davon als falsch herausgestellt hat -- alles, was das Gerät tatsächlich erneut meldet, wird einfach erneut gemerkt, es sei denn, Sie schalten auch „Vom Gerät gemeldete, aber nicht angegebene Modi merken“ in den Geräteeinstellungen aus."
},
"cloud_manual": {
"title": "Download-Programme",
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
"data": {
"download_course": "Programmcode des Download-Programms"
}
},
"cloud_courses": {
"title": "Download-Programme",
"description": "Die geführte Einrichtung begleitet Sie am Gerät: Wählen Sie am Gerät ein heruntergeladenes Programm aus und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.",
"menu_options": {
"cloud_guided": "Geführte Einrichtung",
"cloud_manual": "Namen bearbeiten"
}
},
"cloud_wait": {
"title": "Download-Programme"
},
"cloud_name": {
"title": "Dieses Programm benennen",
"description": "Das Gerät ist zu einem heruntergeladenen Programm gewechselt (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann am Gerät das nächste aus. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
"data": {
"name": "Name",
"download_course": "Programmcode des Download-Programms"
}
},
"cloud_timeout": {
"title": "Kein Programm ausgewählt",
"description": "Am Gerät wurde nichts ausgewählt. Stellen Sie sicher, dass es auf das Download-Programm eingestellt ist, und gehen Sie dann die heruntergeladenen Programme durch.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
"menu_options": {
"cloud_guided": "Erneut warten",
"cloud_finish": "Fertig"
}
}
},
"error": {
"empty_payload": "Geben Sie mindestens ein zu schreibendes Feld ein.",
"write_failed": "Der Schreibvorgang ist fehlgeschlagen. Weitere Details finden Sie im Home-Assistant-Protokoll."
"write_failed": "Der Schreibvorgang ist fehlgeschlagen. Weitere Details finden Sie im Home-Assistant-Protokoll.",
"cloud_course_name_duplicate": "Zwei Programme haben denselben Namen. Namen müssen eindeutig sein.",
"cloud_course_unknown_course": "Dieses Programm wird von diesem Gerät nicht angeboten. Wählen Sie eines aus der Liste."
},
"abort": {
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
},
"progress": {
"cloud_wait": "Wählen Sie jetzt ein heruntergeladenes Programm am Gerät aus.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
}
},
"issues": {
"device_gap": {
"title": "Unvollständige Funktionsabdeckung für {device_name}",
"description": "Für dieses Gerät liegt keine vollständige Funktionsabdeckung vor. Entweder wurde sein Gerätetyp nicht erkannt, oder einige der von ihm bereitgestellten Ressourcen sind noch nicht abgebildet. Es funktioniert weiterhin mit dem, was bereits unterstützt wird. Sie können helfen, die Unterstützung zu erweitern, indem Sie zu Einstellungen > Geräte & Dienste > {device_name} > das Menü (oben rechts) > Diagnose herunterladen gehen und diese anschließend über die verlinkte Issue-Vorlage einreichen."
},
"cloud_courses_undiscovered": {
"title": "Heruntergeladene Programme für {device_name} nicht eingerichtet",
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen."
}
},
"exceptions": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings options",
"menu_options": {
"settings": "Device settings",
"cloud_courses": "Download cycles",
"forget_learned_modes": "Forget remembered modes",
"debug_write": "Debug: write to a resource"
}
@@ -1444,20 +1445,63 @@
"debug_write": "Write another resource",
"finish": "Finish"
}
},
"cloud_manual": {
"title": "Download cycles",
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
"data": {
"download_course": "Download cycle course code"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
"menu_options": {
"cloud_guided": "Guided setup",
"cloud_manual": "Edit names"
}
},
"cloud_wait": {
"title": "Download cycles"
},
"cloud_name": {
"title": "Name this cycle",
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
"data": {
"name": "Name",
"download_course": "Download cycle course code"
}
},
"cloud_timeout": {
"title": "No cycle selected",
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
"menu_options": {
"cloud_guided": "Wait again",
"cloud_finish": "Finish"
}
}
},
"error": {
"empty_payload": "Enter at least one field to write.",
"write_failed": "The write failed. Check the Home Assistant logs for details."
"write_failed": "The write failed. Check the Home Assistant logs for details.",
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.",
"cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
},
"progress": {
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
},
"cloud_courses_undiscovered": {
"title": "Downloaded cycles not set up for {device_name}",
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
}
},
"exceptions": {
@@ -53,6 +53,7 @@
"title": "Opciones de LocalThings",
"menu_options": {
"settings": "Ajustes del dispositivo",
"cloud_courses": "Ciclos descargados",
"forget_learned_modes": "Olvidar los modos recordados",
"debug_write": "Depuración: escribir en un recurso"
}
@@ -91,20 +92,63 @@
"debug_write": "Escribir otro recurso",
"finish": "Finalizar"
}
},
"cloud_manual": {
"title": "Ciclos descargados",
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
"data": {
"download_course": "Código de programa de Descarga de Programas"
}
},
"cloud_courses": {
"title": "Ciclos descargados",
"description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.",
"menu_options": {
"cloud_guided": "Configuración guiada",
"cloud_manual": "Editar nombres"
}
},
"cloud_wait": {
"title": "Ciclos descargados"
},
"cloud_name": {
"title": "Nombra este ciclo",
"description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
"data": {
"name": "Nombre",
"download_course": "Código de programa de Descarga de Programas"
}
},
"cloud_timeout": {
"title": "No se seleccionó ningún ciclo",
"description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
"menu_options": {
"cloud_guided": "Esperar de nuevo",
"cloud_finish": "Finalizar"
}
}
},
"error": {
"empty_payload": "Introduce al menos un campo para escribir.",
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles."
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles.",
"cloud_course_name_duplicate": "Dos ciclos tienen el mismo nombre. Los nombres deben ser únicos.",
"cloud_course_unknown_course": "Ese programa no es uno que ofrezca este dispositivo. Elige uno de la lista."
},
"abort": {
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
},
"progress": {
"cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
}
},
"issues": {
"device_gap": {
"title": "Cobertura de capacidades incompleta para {device_name}",
"description": "A este dispositivo le falta cobertura completa de capacidades. O su tipo de electrodoméstico no fue reconocido, o algunos de los recursos que expone aún no están modelados. Seguirá funcionando con lo que ya está soportado. Puedes ayudar a ampliar el soporte yendo a Ajustes > Dispositivos y servicios > {device_name} > el menú (arriba a la derecha) > Descargar diagnósticos, y reportándolo con la plantilla de incidencia enlazada."
},
"cloud_courses_undiscovered": {
"title": "Ciclos descargados sin configurar para {device_name}",
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí."
}
},
"exceptions": {
@@ -1406,6 +1406,7 @@
"title": "Opzioni LocalThings",
"menu_options": {
"settings": "Impostazioni dispositivo",
"cloud_courses": "Cicli scaricati",
"forget_learned_modes": "Dimentica le modalità memorizzate",
"debug_write": "Debug: scrivi su una risorsa"
}
@@ -1444,20 +1445,63 @@
"debug_write": "Scrivi un'altra risorsa",
"finish": "Fine"
}
},
"cloud_manual": {
"title": "Cicli scaricati",
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
"data": {
"download_course": "Codice programma del ciclo Scaricato"
}
},
"cloud_courses": {
"title": "Cicli scaricati",
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
"menu_options": {
"cloud_guided": "Configurazione guidata",
"cloud_manual": "Modifica nomi"
}
},
"cloud_wait": {
"title": "Cicli scaricati"
},
"cloud_name": {
"title": "Assegna un nome a questo ciclo",
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
"data": {
"name": "Nome",
"download_course": "Codice programma del ciclo Scaricato"
}
},
"cloud_timeout": {
"title": "Nessun ciclo selezionato",
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
"menu_options": {
"cloud_guided": "Attendi di nuovo",
"cloud_finish": "Fine"
}
}
},
"error": {
"empty_payload": "Inserisci almeno un campo da scrivere.",
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli."
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli.",
"cloud_course_name_duplicate": "Due cicli hanno lo stesso nome. I nomi devono essere univoci.",
"cloud_course_unknown_course": "Quel programma non è tra quelli offerti da questo dispositivo. Selezionarne uno dall'elenco."
},
"abort": {
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
},
"progress": {
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
}
},
"issues": {
"device_gap": {
"title": "Copertura delle funzionalità incompleta per {device_name}",
"description": "Questo dispositivo non è completamente supportato. Il tipo di dispositivo non è stato riconosciuto oppure alcune delle risorse che espone non sono ancora state modellate. Continuerò a funzionare con le funzionalità già supportate. Puoi contribuire ad ampliare il supporto andando su Impostazioni > Dispositivi e servizi > {device_name} > il menu (in alto a destra) > Scarica diagnostica, quindi inviando una segnalazione tramite il modulo di segnalazione collegato."
},
"cloud_courses_undiscovered": {
"title": "Cicli scaricati non configurati per {device_name}",
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
}
},
"exceptions": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings 옵션",
"menu_options": {
"settings": "기기 설정",
"cloud_courses": "다운로드 코스",
"forget_learned_modes": "기억된 모드 지우기",
"debug_write": "디버그: 리소스에 쓰기"
}
@@ -1444,20 +1445,63 @@
"debug_write": "다른 리소스에 쓰기",
"finish": "완료"
}
},
"cloud_manual": {
"title": "다운로드 코스",
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
"data": {
"download_course": "다운로드 코스 코드"
}
},
"cloud_courses": {
"title": "다운로드 코스",
"description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.",
"menu_options": {
"cloud_guided": "안내 설정",
"cloud_manual": "이름 편집"
}
},
"cloud_wait": {
"title": "다운로드 코스"
},
"cloud_name": {
"title": "이 코스 이름 지정",
"description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
"data": {
"name": "이름",
"download_course": "다운로드 코스 코드"
}
},
"cloud_timeout": {
"title": "선택된 코스 없음",
"description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
"menu_options": {
"cloud_guided": "다시 대기",
"cloud_finish": "완료"
}
}
},
"error": {
"empty_payload": "쓸 필드를 하나 이상 입력하세요.",
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요."
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.",
"cloud_course_name_duplicate": "두 코스의 이름이 같습니다. 이름은 서로 달라야 합니다.",
"cloud_course_unknown_course": "이 기기가 제공하지 않는 코스입니다. 목록에서 선택하세요."
},
"abort": {
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
},
"progress": {
"cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
}
},
"issues": {
"device_gap": {
"title": "{device_name}의 기능 지원이 완전하지 않음",
"description": "이 기기의 일부 기능이 아직 완전히 지원되지 않습니다. 가전제품 유형이 인식되지 않았거나, 기기가 제공하는 일부 리소스가 아직 구현되지 않았습니다. 현재 지원되는 기능은 계속 사용할 수 있습니다. 설정 > 기기 및 서비스 > {device_name} > 오른쪽 위 메뉴 > 진단 정보 다운로드로 이동하여 진단 정보를 내려받은 뒤, 연결된 이슈 양식에 첨부하면 지원 범위를 넓히는 데 도움을 줄 수 있습니다."
},
"cloud_courses_undiscovered": {
"title": "{device_name}의 다운로드 코스가 설정되지 않음",
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요."
}
},
"exceptions": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings-opties",
"menu_options": {
"settings": "Apparaatinstellingen",
"cloud_courses": "Gedownloade programma's",
"forget_learned_modes": "Onthouden modi vergeten",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
@@ -1444,20 +1445,63 @@
"debug_write": "Naar een andere resource schrijven",
"finish": "Voltooien"
}
},
"cloud_manual": {
"title": "Gedownloade programma's",
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
"data": {
"download_course": "Programmacode van \"Gedownload\""
}
},
"cloud_courses": {
"title": "Gedownloade programma's",
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
"menu_options": {
"cloud_guided": "Begeleide installatie",
"cloud_manual": "Namen bewerken"
}
},
"cloud_wait": {
"title": "Gedownloade programma's"
},
"cloud_name": {
"title": "Geef dit programma een naam",
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
"data": {
"name": "Naam",
"download_course": "Programmacode van \"Gedownload\""
}
},
"cloud_timeout": {
"title": "Geen programma geselecteerd",
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
"menu_options": {
"cloud_guided": "Opnieuw wachten",
"cloud_finish": "Voltooien"
}
}
},
"error": {
"empty_payload": "Voer ten minste één veld in om te schrijven.",
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.",
"cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.",
"cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst."
},
"abort": {
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
},
"progress": {
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
}
},
"issues": {
"device_gap": {
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
},
"cloud_courses_undiscovered": {
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
}
},
"exceptions": {
+279
View File
@@ -0,0 +1,279 @@
# Laundry cloud "Download" cycles: solved, with one dead end
`registry/capabilities/laundry.py`'s cycle select offers a washer's
downloaded ("Download" / "Downloaded") programs alongside its ordinary
courses now, driven by the store in `cloudcourse.py` (issue #342). This file
records the byte-level work behind it, including a decode that fit one
device perfectly and collapsed on the second — the reason nothing in the
shipped code interprets a program payload at all.
Four devices in the corpus carry these tokens (a survey of every laundry
diagnostics dump attached to an issue turned up 14 devices; the other 10 have
no cloud tokens at all, so this is a minority feature):
| dump | model | slots advertised | payloads seen | blob width |
| --- | --- | --- | --- | --- |
| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes |
| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes |
| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — |
| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
Three things follow immediately from that table:
- **`CloudExtraCourse_` does not mean the same thing on every family.** On
the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
dishwasher's *own* course list, three already translated (Plastic, Pots and
pans, Baby Care). There it tags which ordinary courses came from the cloud;
they select with a plain `Course_` write and need no payload — consistent
with it carrying no payload token at all. It also has a
`DownloadCourseList_8F` token the washers lack.
On both washers the slots share **zero** overlap with the course list and a
payload is required. Subtracting the course list is what tells the two
apart (`cloudcourse.cloud_slots`), so the feature engages on the washers
and correctly does nothing on the dishwasher.
- **A device can advertise a slot it has never loaded.** True on the washers
too — nothing about a program is learnable until its owner runs it, which
is what the Repairs issue exists to explain.
- **Both WW5000C units advertise the byte-identical slot list**
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
firmware builds. Either the set is a factory/regional default rather than
something each owner curates, or the two dumps share an owner — unresolved,
but worth knowing before assuming a user picked their own programs.
## The three tokens
All on `/course/vs/0`'s `x.com.samsung.da.options` array, same
prefix-match/replace merge as every other token there.
- `CloudExtraCourse_<slot><slot>…` — the device's own list of downloaded
program slots, one byte each. The cloud counterpart of `EditCourseList_`.
- `CloudCourse_<blob>` — the persisted default program.
- `OneTimeCloudCourse_<blob>` — a this-run-only override.
### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot
The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for
its nine downloaded programs. Byte 2 of each of the nine blobs its owner
captured is exactly one of those nine, no repeats, sets equal:
```
blob byte2 program
00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports
00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only
00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor
00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans
00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet
00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive
00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven
00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts
00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels
CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A
```
Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its
`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its
`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an
advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather
than naming a program. That sentinel is why `cloudcourse.is_loaded` exists.
This is what makes "3 of 9 discovered" answerable, and it is why no catalog
of program ids is hardcoded anywhere: the appliance already knows which
programs it has.
## Writing: the two-token rule
Confirmed on hardware by the issue #342 reporter. Writing
`OneTimeCloudCourse_<blob>` alone while some other course is selected is
accepted at the protocol level (no error) and then silently ignored by the
machine. It takes effect only when the same write also switches `Course_` to
the Download course — which is what `laundry._cloud_cycle_write` does, and
the only two-token options write in the codebase:
```yaml
x.com.samsung.da.options:
- Course_87
- OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00
```
`CloudCourse_` was separately confirmed writable on its own: set while on
Download it changes the running program; set from another course it becomes
what gets preselected the next time Download is chosen. The integration
doesn't write it today — a "default download cycle" control is a possible
follow-up, deliberately left out of the first pass.
### There is no single "Download" course code
The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17`
("Downloaded" in `washer_cycle_table_02`). **Same course table, different
code.** Any per-table lookup of "the Download code" would have been wrong on
one of the only two devices available to check it against, which is why the
code is learned by observation and confirmed by the user in the options flow
instead of tabled.
The observation signal is "whatever `Course_` reads at the moment a
non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that
was actually watched, not a state. Tokens in this array are replaced by
prefix and never evicted, so a payload merely sitting there says nothing
about when it got there; on the first rep after a restart it is equally
consistent with "just loaded" and "left over from last week". Believing it
would propose whatever ordinary course the appliance happens to be sitting
on, and accepting that prefill starts a real wash cycle. Even a genuine
transition is only ever a *candidate*, confirmed by the user before use.
Both dumps in the corpus taken while off the Download course
(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`)
show the appliance clearing its one-time token to the `FFFF` sentinel, so
the saved default persists but the one-shot does not. That makes the stale
case unlikely on these boards — which is a reason to expect it to behave,
not a reason to depend on it.
## The dead end: bytes 5/7/9 do not decode portably
With the nine WW5000C programs and their app-reported settings side by side,
three of the varying bytes fit perfectly:
| byte | meaning | formula | fit |
| --- | --- | --- | --- |
| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 |
| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 |
| 9 | spin level | `(b - 0x90) / 8` | 9/9 |
Nine for nine, including the internally consistent case: "Spin only" is the
only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that
skips washing entirely while still reporting a spin level.
It does not survive the second device. Against the WA55A7700AV's
`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`:
- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C**
- spin: `(0x22 - 0x90) / 8` = **negative**
- rinse: `0x11 - 0x10` = 1 — the only plausible one
### What *does* survive is the grammar
The two boards' payloads are different lengths (20 vs 16 bytes) but not a
different format — same header, same leading fields, two fewer optional
trailing ones:
```
WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00
WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22
```
- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical
layout on both.
- Then a tag/value stream whose **first four tags are the same, in the same
order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the
four whose values vary per program.
- Then a fixed tail, terminated on both by an `AC:<value>` pair. The entire
width difference is two trailing pairs the WA55 doesn't carry.
The tail is not program data. Across all nine WW5000C programs bytes 12–19
are byte-identical — every trailing pair carries value `F0` except the `AC`
terminator, and `35` vs `37` looks like a board or profile marker rather than
a field. (It is not a field count either: the board with the *higher* leading
byte has *fewer* pairs.)
### Byte 3 is not part of a program's identity
Worth its own heading, because it is the single strongest argument against
ever shipping a table of payloads. The second WW5000C (issues #259/#343,
firmware `_B048`) has its saved `CloudCourse` set to the same program as the
first one's "Towels" capture — and the two payloads differ at exactly one
byte:
```
_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
^^
```
Same program id, same slot, same values on all four varying tags, same tail.
Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both
are WW5000C) nor a property of the program (identical in every other
respect) — most likely a download revision or sequence counter.
A hardcoded catalog keyed on program id would therefore have shipped one
unit's byte 3 to the other unit. Whether the appliance would reject that, or
accept it and do something unintended, is untested and does not need to be:
every payload is learned from the device it will be replayed to.
### Sentinels
The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on
the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its
byte 2 is *not* reliably meaningful: it equals the currently selected course
on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on
`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF`
prefix, and its byte 2 is not an advertised slot in either dump anyway.
So the payload is tag/value, not fixed offsets — but knowing the grammar
doesn't recover the values. The same four tags carry non-overlapping ranges
between the two boards:
| tag | WW5000C (9 programs) | WA55 |
| --- | --- | --- |
| `49` | `00, 28, 30, 40, 58` | `16` |
| `4D` | `00, 12, 13, 14` | `11` |
| `4A` | `A0, B0, B8` | `22` |
| `4C` | `00` (all nine) | `20` |
Same field, board-specific encoding. Decoding it properly needs a third
device; one device's fit is a coincidence-shaped hypothesis, not a format.
**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads
Warm / High / 1, which looks like it could confirm a decode of that unit's
`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal),
not on its cloud course, so those values describe the local cycle it has
selected, not the saved cloud program. A cross-check like this is only
evidence when the machine is actually loaded with the program being decoded.
So the shipped code never interprets a payload: a blob is recorded whole and
replayed byte-for-byte, exactly as the device reported it, and never
decomposed or rebuilt. The read-only "loaded program's temperature/spin"
sensors this decode would have enabled were dropped for the same reason.
## What is deliberately not done
- **No hardcoded program catalog.** Blobs are cloud-assigned per
account/region. One owner's captured payload is not evidence about anyone
else's appliance, and a table of them would offer options that write
another household's wash settings.
- **No invented names.** The appliance reports an opaque slot id and nothing
else. Names come from the user in the options flow, the same rule that
stops an unrecognized local course code from getting a made-up English
label (PR #251 review).
- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing
builds a payload from parts — the device was never tested with one, and a
fabricated blob is an untested write to a wash cycle.
## Open questions for the next dump
1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the
course changes? Behavior suggests the appliance falls back to
`CloudCourse_` when Download is re-entered, but the token's own lifecycle
is unconfirmed. `laundry.cloud_current` is written to be correct either
way.
2. What does byte 1 mean? It is distinct per program and *sometimes*
coincides with a plausible local course code for that program (`2E` Baby
Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and
sometimes doesn't (`21` Colors for "Sports"). Probably a base-course
reference; not reliable enough to use.
3. What does byte 3 count? It moves between two units holding the identical
program (`04` vs `06`) and is `00` on every sentinel. A revision or
download counter is the obvious guess; a dump taken before and after
re-downloading the same program would confirm it.
4. **The value encoding is still open, and a third *washer* won't
necessarily settle it.** The survey found one, but its saved program is a
duplicate of one already captured, so it adds no new tag values. What is
actually needed is a dump from a board whose `/washer/vs/0` speaks in
named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a
downloaded program* — then the payload's `49`/`4A` values can be read
against settings that describe the same program. The WA55 is such a
board but was captured on a local course, which is why it can't be used
(see the trap above).
5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as
dishwasher course codes (its selected course is `86`), unlike the
washers' slots. One payload from that machine would show whether slot ids
are drawn from the course-code space on some boards.
+297
View File
@@ -0,0 +1,297 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "DishA_Disable",
"x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0812",
"UpdateAllow_NotAllowed",
"CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D",
"CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C",
"CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C",
"Course_86",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"ProgressTimeSet_420622820456B20384",
"SendToDevice_Off",
"GMT_F2",
"SavingModeCondition_01010207828485868E8D020300",
"SavingMode_Off",
"DownloadCourseList_8F",
"StormWashZone_Off",
"AutoDoorRelease_On",
"Sound_On",
"WaterLevelSet_050404050302010205010400",
"CloudExtraCourse_8E8D8F02",
"EnergyLevelSet_050403050202010305040300",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_Empty",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Run",
"x.com.samsung.da.remainingTime": "00:44:00",
"x.com.samsung.da.progressPercentage": "28",
"x.com.samsung.da.delayStartTime": "00:00:00",
"x.com.samsung.da.progress": "Wash",
"x.com.samsung.da.supportedProgress": [
"None",
"Predrain",
"Wash",
"Rinse",
"Drying",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Predrain",
"Wash",
"Rinse",
"Drying",
"Finish"
],
"currentJobState": "Wash",
"remainingTime": "00:44:00",
"progressPercentage": "28"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "WD0",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "00081A230213(A214)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "23060214,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/dishwasher/vs/0",
"rep": {
"x.com.samsung.da.highTemperatureDry": "Off",
"x.com.samsung.da.sanitize": "Off",
"x.com.samsung.da.selectedZone": "ON_ON",
"x.com.samsung.da.rinseLevel": "0",
"x.com.samsung.da.supportedSelectedZone": [
"OFF_ON",
"ON_ON"
],
"x.com.samsung.da.supportedSanitize": [
"Off",
"On"
],
"x.com.samsung.da.supportedHighTemperatureDry": [
"Off",
"On"
],
"x.com.samsung.da.supportedRinseLevel": [
"0",
"1",
"2",
"3",
"4",
"5",
"6"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": true
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "CA"
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/water/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeWater": "2662000"
}
},
{
"href": "/wm/submode/vs/0",
"rep": {
"setTemperatureUnit": "F"
}
}
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"state_keys": [
"alarm_code",
"auto_release_dry",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_saved_kwh",
"filter_status",
"filter_usage",
"finish_time",
"firmware_update",
"heated_dry",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"sanitize",
"storm_wash",
"water_liters"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"state_keys": [
"alarm_code",
"bubble_soak",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"energy_saved_kwh",
"finish_sound",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"softener_low",
"spin_speed",
"wash_temperature",
"water_liters"
]
}
+452
View File
@@ -0,0 +1,452 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "10",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "74800",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1786287600",
"x.com.samsung.da.cumulativeDateUTC": "1786280400",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0167",
"UpdateAllow_NotAllowed",
"Course_87",
"LaundryOutTime_0",
"SeamlessControl_Enable",
"KidsLockBypass_On",
"WashingTimes_0",
"DrumCleanProposal_40",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"SoftenerOnce_0",
"SoftenerLeft_0",
"SoftenerBase_0",
"SoftenerAlarm_Off",
"SoftenerType_0",
"SoftenerTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_74",
"BubbleSoak_Off",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_421C20820437A2042C",
"SendToDevice_On",
"GMT_04",
"PreWashSetting_Off",
"IntensiveSetting_Off",
"CloudCourse_0021550449284D134AA04C0035F004F005F0AC00",
"CloudExtraCourse_0A5C286B2D0C55301A",
"OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00",
"BubbleSoakSet_00F0F0F00000F0F000000000F000",
"EnergyLevelSet_050304040501050404030201020401",
"MostUsed_1B841E923FA67F00000000000000",
"PreWashAvailableSet_F0F0F0F00000F0F000F0F000F000",
"IntensiveAvailableSet_F0F0F0F00000F0F000F0F000F000",
"TextureLevel_None",
"SavingModeCondition_010102151C1BA0961C8F251C0A061A207F5C55656B0C2D3034030149035E2830",
"SavingMode_Off",
"WelcomeLighting_1",
"SupportedWelcomeLighting_000102",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2025-08-18T14:56:34|2025-11-02T21:08:39|2026-01-19T13:00:50|2026-03-23T12:10:12|2026-06-27T11:07:22|2026-08-09T10:13:44",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F088000913FA67F7485209204A5208780009000A00006841E930FA30F7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F34841E923FA67FA0811E933FA33F"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "01:14:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "01:14:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B06C",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "WF1",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02986A260118(A182)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20348141240110090FFFFF203495412406195503FFFF(01672034814120349541_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03481A24011009,03495A24061955",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2025984624053003032FFFFFFFFFFFFFFFFFFFFFFFFE(016720259846FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02598F24053003,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "30",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"20",
"30",
"40",
"60",
"90"
],
"x.com.samsung.da.spinLevel": "800",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"400",
"800",
"1000",
"1200",
"1400"
],
"x.com.samsung.da.rinseCycles": "3",
"x.com.samsung.da.supportedRinseCycles": [
"0",
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_02_Course_87",
"x.com.samsung.da.st.courseTable": "Table_02"
}
},
{
"href": "/water/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeWater": "7512800"
}
},
{
"href": "/setting/vs/0",
"rep": {
"x.com.samsung.da.supportedSetLanguage": [
"ko_KR",
"en_US"
]
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
"x.com.samsung.da.modelCode": "M(None),W(WW8XCGC04AAEEG)"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-03-04",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_WM_TP1_21_COMMON",
"versions": [
"30260118"
],
"visVersion": "260118"
},
{
"type": "Micom",
"modelId": "01672034814120349541",
"versions": [
"24011009",
"24061955"
],
"visVersion": "240619"
},
{
"type": "Micom",
"modelId": "016720259846FFFFFFFF",
"versions": [
"24053003",
"FFFFFFFF"
],
"visVersion": "240530"
},
{
"type": "Micom",
"modelId": "016720259846FFFFFFFF",
"versions": [
"24053003",
"FFFFFFFF"
],
"visVersion": "240530"
}
]
}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Volume_Off",
"Volume_Low",
"Volume_Med",
"Volume_High"
],
"setBuzzerSound": "Volume_Low",
"supportedFinishSound": [
"FinishSound_1",
"FinishSound_2",
"FinishSound_3"
],
"setFinishSound": "FinishSound_2"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "DE"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Berlin",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+439
View File
@@ -0,0 +1,439 @@
"""Cloud "Download" programs on laundry devices (issue #342).
The store and blob parsing live in cloudcourse.py; how they reach the cycle
select lives in registry/capabilities/laundry.py. Both are covered here,
against the two real dumps in the corpus that carry these tokens:
washer_ww5000c_cloud the issue #342 reporter's WW5000C, sitting on
Download with a one-time Jeans override loaded over
a saved Sports default. Advertises nine programs.
washer_wa55a7700av a WA55A7700AV sitting on an ordinary local course,
with a saved program and the FFFF "none" sentinel in
the one-time slot. Advertises two programs.
"""
import pytest
from custom_components.localthings import cloudcourse
from custom_components.localthings.registry.capabilities import laundry, washer
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device
# The reporter's nine captured programs, keyed by the slot byte the device
# itself advertises. Only used to drive the tests -- nothing in the shipped
# code carries a table of these (see cloudcourse.py's module docstring).
SPORTS = "0021550449284D134AA04C0035F004F005F0AC00"
JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00"
TOWELS = "00040A0449404D134AB84C0035F004F005F0AC00"
def _cycle_desc():
return next(
e for e in washer.WASHER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
)
def _rep(options, cloud=None):
rep = {"x.com.samsung.da.options": list(options)}
if cloud is not None:
rep[cloudcourse.FIELD] = cloud
return rep
class TestBlobParsing:
def test_slot_is_byte_two(self):
"""Confirmed against every program in both dumps: byte 2 of a blob
is the slot id its own CloudExtraCourse_ token advertises."""
assert cloudcourse.slot_of(SPORTS) == "55"
assert cloudcourse.slot_of(JEANS) == "6B"
assert cloudcourse.slot_of(TOWELS) == "0A"
def test_ffff_prefix_is_not_a_program(self):
"""WA55A7700AV reports this while sitting on a local course -- it
means 'no one-time override', not a program, and its byte 2 is not
one of the slots that device advertises."""
sentinel = "FFFF010049004D004A804C0037F0AC00"
assert cloudcourse.is_loaded(sentinel) is False
assert cloudcourse.slot_of(sentinel) is None
@pytest.mark.parametrize(
"bad",
[None, "", "zz", "0021", "0021550449284D134AA04C0035F004F005F0AC0"],
)
def test_malformed_blobs_yield_no_slot(self, bad):
assert cloudcourse.slot_of(bad) is None
def test_advertised_slots_preserve_device_order(self):
rep = _rep(["CloudExtraCourse_0A5C286B2D0C55301A"])
assert cloudcourse.advertised_slots(rep) == [
"0A",
"5C",
"28",
"6B",
"2D",
"0C",
"55",
"30",
"1A",
]
def test_no_cloud_tokens_means_unsupported(self):
assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"]), ["1C"]) is False
class TestRealDumps:
def test_reporter_dump_advertises_nine_and_learns_both_loaded_blobs(self):
"""One poll teaches two programs: the saved default and the loaded
one-time override are different programs on this dump."""
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
assert len(cloudcourse.advertised_slots(rep)) == 9
store = cloudcourse.CloudCourses()
assert store.observe(rep) is True
assert store.snapshot()["slots"]["55"]["blob"] == SPORTS
assert store.snapshot()["slots"]["6B"]["blob"] == JEANS
# Learned but unnamed -- nothing is offerable yet.
assert store.named() == {}
assert store.view() == {}
def test_reporter_dump_proposes_its_download_course(self):
"""Only once a load has actually been watched. A single observation
can't tell "just loaded" from "left over", so the dump alone proposes
nothing -- see test_a_single_observation_proposes_nothing."""
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
store.observe(rep) # one-time token moves to Jeans while on Course_87
assert store.download_candidates() == ["87"]
# A candidate is never used until confirmed.
assert store.snapshot()["download_course"] is None
def test_a_single_observation_proposes_nothing(self):
"""The case a user hits after any restart: the appliance has cloud
payloads from previous runs and is sitting on an ordinary course. If
the board doesn't clear its one-time token, believing the first
observation would propose that ordinary course as the Download one --
and accepting the prefill would start a real wash cycle."""
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]))
assert store.download_candidates() == []
# The payload is still learned -- that part is device fact.
assert store.snapshot()["slots"]["55"]["blob"] == SPORTS
def test_wa55_learns_its_saved_program_but_not_the_sentinel(self):
rep = _load_device("washer_wa55a7700av")["/course/vs/0"]
assert cloudcourse.advertised_slots(rep) == ["59", "58"]
store = cloudcourse.CloudCourses()
store.observe(rep)
assert store.snapshot()["slots"]["59"]["blob"] == "001C590549164D114A224C2037F0AC22"
assert "01" not in store.snapshot()["slots"] # the FFFF sentinel's byte 2
def test_wa55_proposes_no_download_course(self):
"""It is sitting on an ordinary local course with no override
loaded, so there is nothing to infer -- exactly the case where
guessing a code would start the wrong cycle."""
rep = _load_device("washer_wa55a7700av")["/course/vs/0"]
store = cloudcourse.CloudCourses()
store.observe(rep)
assert store.download_candidates() == []
def test_a_dishwasher_tags_its_own_courses_not_payload_slots(self):
"""DW5000C (issues #113/#123). Its CloudExtraCourse_ names four
bytes, and all four are course codes in its *own* course list --
8E/8D/8F/02, three of them already translated (Plastic, Pots and
pans, Baby Care). There it marks which ordinary courses came from
the cloud; they select with a plain Course_ write and need nothing
from this module. It carries no payload token at all, consistent
with that.
So none of this feature applies to it, and offering its owner a
naming flow for programs that already work would be nonsense. The
washers are the other shape: zero overlap with their course lists,
and a payload required to select one."""
resources = _load_device("dishwasher_dw5000c_cloud")
rep = resources["/course/vs/0"]
courses = laundry.cycle_options(resources)
assert cloudcourse.advertised_slots(rep) == ["8E", "8D", "8F", "02"]
assert set(cloudcourse.advertised_slots(rep)) <= set(courses)
assert cloudcourse.cloud_slots(rep, courses) == []
assert cloudcourse.supports_cloud_courses(rep, courses) is False
assert cloudcourse.undiscovered(rep, cloudcourse.CloudCourses().snapshot(), courses) == []
def test_washer_slots_share_nothing_with_the_course_list(self):
"""The distinguishing property, on both washers -- which is what
makes subtracting the course list a safe way to tell the two
meanings of CloudExtraCourse_ apart."""
for name in ("washer_ww5000c_cloud", "washer_wa55a7700av"):
resources = _load_device(name)
rep = resources["/course/vs/0"]
courses = laundry.cycle_options(resources)
assert set(cloudcourse.advertised_slots(rep)).isdisjoint(courses), name
assert cloudcourse.supports_cloud_courses(rep, courses) is True, name
def test_byte_three_is_not_part_of_a_programs_identity(self):
"""Two WW5000C units on different firmware (issue #342's _B06C and
issues #259/#343's _B048) report the same program -- same id, same
slot, same field values, same tail -- differing only at byte 3.
This is the evidence against a shipped catalog of payloads: keyed on
program id, one unit's capture would have carried the other unit's
byte 3. Both are recorded and replayed per device instead, so
whatever byte 3 tracks never has to be understood."""
b06c = "00040A0449404D134AB84C0035F004F005F0AC00"
b048 = "00040A0649404D134AB84C0035F004F005F0AC00"
assert b06c != b048
# Same program by every identifier the device gives us.
assert cloudcourse.slot_of(b06c) == cloudcourse.slot_of(b048) == "0A"
assert b06c[:6] == b048[:6]
assert b06c[8:] == b048[8:]
# Learned separately per device, and each replays what it saw.
for blob in (b06c, b048):
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"]))
assert store.snapshot()["slots"]["0A"]["blob"] == blob
def test_a_sentinel_slot_byte_is_not_the_current_course(self):
"""The two sentinels in the corpus disagree about this -- WA55's byte
2 happens to equal its selected course, the WW5000C _B048's does not
(1B against Course_1C). Nothing keys off it: a sentinel is rejected
on its FFFF prefix, and its byte 2 is not an advertised slot either
way."""
wa55 = "FFFF010049004D004A804C0037F0AC00"
b048 = "FFFF1B0049004D004A804C0035F004F005F0AC00"
assert cloudcourse.slot_of(wa55) is None
assert cloudcourse.slot_of(b048) is None
# Different board widths, same sentinel rule.
assert len(wa55) != len(b048)
def test_undiscovered_counts_against_what_the_device_advertises(self):
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
store = cloudcourse.CloudCourses()
store.observe(rep)
# Both learned slots are still unnamed, so all nine are outstanding.
courses = laundry.cycle_options(_load_device("washer_ww5000c_cloud"))
assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 9
store.set_name("55", "Sports")
assert "55" not in cloudcourse.undiscovered(rep, store.snapshot(), courses)
assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 8
class TestStoreRules:
def test_only_advertised_slots_are_recorded(self):
"""A blob for a slot this appliance doesn't list isn't a program it
offers."""
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"]))
assert "6B" not in store.snapshot()["slots"]
def test_a_relearned_blob_replaces_the_old_payload(self):
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
rewritten = SPORTS.replace("F005F0AC00", "F005F0AC11")
assert store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{rewritten}"])) is True
assert store.snapshot()["slots"]["55"]["blob"] == rewritten
def test_observing_the_same_rep_twice_changes_nothing(self):
store = cloudcourse.CloudCourses()
rep = _rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])
assert store.observe(rep) is True
assert store.observe(rep) is False
def test_candidates_rank_by_program_loads_not_by_dwell_time(self):
"""A stale OneTimeCloudCourse_ token outlives the run it belonged to,
so it is still reported through every poll the appliance spends on
whatever ordinary course comes next. Counting polls would rank that
course first and suggest it as the Download course -- and accepting
the suggestion would start a real wash cycle. Only the poll where the
payload actually changed is evidence."""
store = cloudcourse.CloudCourses()
loaded = _rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])
stale = _rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"])
store.observe(_rep(["CloudExtraCourse_55", "Course_87"])) # baseline
store.observe(loaded)
for _ in range(200): # ~100 minutes sitting on a cotton cycle
store.observe(stale)
assert store.download_candidates() == ["87"]
def test_a_second_distinct_load_is_counted(self):
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_556B", "Course_87"])) # baseline
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{JEANS}"]))
assert store.download_candidates() == ["87"]
def test_view_is_empty_until_a_download_course_is_confirmed(self):
"""Both halves are required: without the course code there is no
write to build, so nothing should reach the select."""
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
store.set_name("55", "Sports")
assert store.view() == {}
store.set_download_course("87")
assert store.view()["programs"] == {"55": {"blob": SPORTS, "name": "Sports"}}
def test_round_trips_through_the_entry(self):
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
store.set_name("55", "Sports")
store.set_download_course("87")
restored = cloudcourse.CloudCourses(store.snapshot())
assert restored.snapshot() == store.snapshot()
assert restored.view() == store.view()
@pytest.mark.parametrize(
"junk",
[
"not a dict",
{"slots": "nope"},
{"slots": {"55": {"blob": "garbage", "name": "Sports"}}},
# blob's own byte 2 disagrees with the key it is filed under
{"slots": {"99": {"blob": SPORTS, "name": "Sports"}}},
],
)
def test_a_hand_edited_entry_cannot_crash_setup(self, junk):
assert cloudcourse.CloudCourses(junk).snapshot()["slots"] == {}
class TestCycleSelectIntegration:
"""The cloud programs ride in the ordinary cycle select -- on the
appliance's dial, Download is one course among the rest."""
def _cloud(self):
return {
"download_course": "87",
"programs": {"55": {"blob": SPORTS, "name": "Sports"}},
}
def test_local_courses_are_unchanged_without_cloud_data(self):
desc = _cycle_desc()
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}}
assert desc.options(live) == ["1C", "1D"]
def test_named_programs_join_the_option_list_after_local_courses(self):
desc = _cycle_desc()
live = {
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C87"},
"/course/vs/0": _rep(["Course_87"], self._cloud()),
}
assert desc.options(live) == ["1C", "87", "cloud:55"]
def test_an_unnamed_program_is_never_offered(self):
desc = _cycle_desc()
live = {
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C"},
"/course/vs/0": _rep(
["Course_87"],
{"download_course": "87", "programs": {}},
),
}
assert desc.options(live) == ["1C"]
def test_label_is_the_users_own_name(self):
desc = _cycle_desc()
resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())}
assert desc.display_fn("cloud:55", resources) == "Sports"
def test_unknown_cloud_value_gets_no_invented_label(self):
desc = _cycle_desc()
resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())}
assert desc.display_fn("cloud:99", resources) is None
def test_state_reports_the_loaded_program_while_on_download(self):
desc = _cycle_desc()
rep = _rep(["Course_87", f"OneTimeCloudCourse_{SPORTS}"], self._cloud())
assert desc.rep_fn(rep) == "cloud:55"
def test_state_falls_back_to_the_saved_program(self):
"""No one-time override loaded: the appliance runs the saved default,
which the reporter confirmed by leaving Download and returning."""
desc = _cycle_desc()
rep = _rep(
["Course_87", f"CloudCourse_{SPORTS}", "OneTimeCloudCourse_FFFF010049004D004A804C00"],
self._cloud(),
)
assert desc.rep_fn(rep) == "cloud:55"
def test_a_stale_program_token_is_not_reported_on_a_local_course(self):
"""Tokens are replaced by prefix and never evicted, so a one-time
program outlives its run. Reporting 'Sports' while a cotton cycle
runs would be a live lie about what the machine is doing."""
desc = _cycle_desc()
rep = _rep(["Course_1C", f"OneTimeCloudCourse_{SPORTS}"], self._cloud())
assert desc.rep_fn(rep) == "1C"
def test_selecting_a_program_switches_course_and_loads_it_in_one_write(self):
"""Confirmed on hardware: writing the program token alone while
another course is selected is silently ignored."""
desc = _cycle_desc()
rep = _rep(["Course_1C"], self._cloud())
path, body = desc.write_fn("cloud:55", rep)
assert path == ["course", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Course_87", f"OneTimeCloudCourse_{SPORTS}"]}
def test_selecting_a_local_course_still_writes_one_token(self):
desc = _cycle_desc()
rep = _rep(["Course_87"], self._cloud())
_, body = desc.write_fn("1C", rep)
assert body == {"x.com.samsung.da.options": ["Course_1C"]}
def test_no_write_without_a_confirmed_download_course(self):
desc = _cycle_desc()
rep = _rep(["Course_1C"], {"programs": {"55": {"blob": SPORTS, "name": "Sports"}}})
assert desc.write_fn("cloud:55", rep) is None
def test_no_write_for_an_unknown_program(self):
desc = _cycle_desc()
rep = _rep(["Course_1C"], self._cloud())
assert desc.write_fn("cloud:99", rep) is None
class TestOptionsMergePreservesSiblingTokens:
def test_two_token_write_merges_like_the_device_does(self):
"""The write carries only the changed tokens; merge_options_field is
what keeps the optimistic cache entry complete during the settle
window. Both tokens must land, and unrelated ones must survive."""
from custom_components.localthings.registry.capabilities.common import merge_options_field
cached = [
"DeviceType_0167",
"Course_1C",
f"CloudCourse_{SPORTS}",
"CloudExtraCourse_0A5C286B2D0C55301A",
f"OneTimeCloudCourse_{TOWELS}",
"GMT_04",
]
merged = merge_options_field(cached, ["Course_87", f"OneTimeCloudCourse_{SPORTS}"])
assert "Course_87" in merged
assert f"OneTimeCloudCourse_{SPORTS}" in merged
# The saved default and the device's own slot list are untouched.
assert f"CloudCourse_{SPORTS}" in merged
assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged
assert "DeviceType_0167" in merged
def test_cloud_prefixes_do_not_poison_the_course_lookup(self):
"""'Course_' is a prefix of neither 'CloudCourse_' nor
'OneTimeCloudCourse_' only because option_value anchors at position
0 -- one character away from being wrong, so it is pinned."""
options = [f"CloudCourse_{SPORTS}", f"OneTimeCloudCourse_{JEANS}", "Course_1C"]
assert laundry.option_value(options, "Course") == "1C"
assert cloudcourse.option_value(options, "Course") == "1C"
def test_a_payload_appearing_while_watching_is_a_real_transition(self):
"""Absent-then-loaded is the user selecting a program, and must count
-- only "never observed at all" suppresses a candidate."""
store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", "Course_87"]))
store.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
assert store.download_candidates() == ["87"]
def test_a_stale_payload_across_a_restart_proposes_nothing(self):
"""A restored store starts unobserved again on purpose: the first rep
after a restart is indistinguishable from a stale one, whatever was
persisted."""
seeded = cloudcourse.CloudCourses()
seeded.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
restored = cloudcourse.CloudCourses(seeded.snapshot())
restored.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]))
assert restored.download_candidates() == []
+786
View File
@@ -0,0 +1,786 @@
"""End-to-end wiring for cloud "Download" cycles (issue #342).
The store's own rules are covered in test_cloud_courses.py. This file covers
the parts that only exist once a coordinator is running: learning from an
applied rep, persisting to the config entry, surfacing the store to entity
descriptors through the synthetic field, the Repairs nudge, and the options
flow that supplies the names.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import Any, cast
from unittest.mock import AsyncMock
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import cloudcourse
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.registry.subdevices import MAIN
from tests.conftest import _load_device
from tests.test_subdevice_discovery import ENTRY_DATA
@pytest.fixture(autouse=True)
def _fast_guided_waits(monkeypatch):
"""Guided setup polls the appliance on a human timescale. Left at its
real values a single lingering round would hold the suite for its whole
timeout, since async_block_till_done waits on the task."""
from custom_components.localthings import config_flow as cf
monkeypatch.setattr(cf, "_CLOUD_PROBE_INTERVAL_S", 0.01)
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.5)
FIXTURE = "washer_ww5000c_cloud"
COURSE = cloudcourse.COURSE_HREF
SPORTS = "0021550449284D134AA04C0035F004F005F0AC00"
JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00"
async def _flush(hass: HomeAssistant) -> None:
"""hass.add_job hops used by the persist path."""
for _ in range(3):
await asyncio.sleep(0)
await hass.async_block_till_done()
def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, **(data or {})},
unique_id="localthings_CLOUD-COURSE-TEST",
)
entry.add_to_hass(hass)
return entry
async def _coordinator(hass: HomeAssistant, entry=None) -> LocalThingsCoordinator:
coordinator = LocalThingsCoordinator(hass, entry or _entry(hass))
resources = _load_device(FIXTURE)
coordinator._run_discovery(resources)
for href, rep in resources.items():
coordinator._observe.apply(href, rep, source="poll")
await _flush(hass)
return coordinator
def _cycle_state(coordinator: LocalThingsCoordinator):
from custom_components.localthings.registry.adapter import flatten
return flatten(coordinator.bound, coordinator.entity_resources()).get("cycle")
def _cycle_desc(coordinator: LocalThingsCoordinator) -> SelectDesc:
return cast(SelectDesc, next(b.desc for b in coordinator.bound if b.desc.key == "cycle"))
def _cycle_options(coordinator: LocalThingsCoordinator):
from custom_components.localthings.registry.subdevices import MAIN
return _cycle_desc(coordinator).options(coordinator.canonical_resources(MAIN))
async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
assert coordinator.cloud_courses.snapshot()["slots"]["6B"]["blob"] == JEANS
# Survives a restart -- the payload is only visible while loaded.
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
"""An optimistic rep is what this integration just wrote, not what the
appliance reported."""
coordinator = LocalThingsCoordinator(hass, _entry(hass))
coordinator._observe.apply(
COURSE,
{"x.com.samsung.da.options": ["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]},
source="optimistic",
)
await _flush(hass)
assert "55" not in coordinator.cloud_courses.snapshot()["slots"]
async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant):
coordinator = await _coordinator(hass)
options = _cycle_options(coordinator)
assert all(not o.startswith(cloudcourse.RAW_PREFIX) for o in options)
# The local courses are untouched.
assert "87" in options
async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant):
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
# The fixture is sitting on Download with a Jeans one-time override, and
# Jeans is still unnamed -- so the state falls through to the raw course.
assert _cycle_state(coordinator) == "87"
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
await _flush(hass)
assert _cycle_state(coordinator) == "cloud:6B"
async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeAssistant):
"""It is merged at read time only: last_resources stays exactly what the
appliance reported, so it can't be polled over, written back, or land in
a diagnostics dump."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE]
assert cloudcourse.FIELD not in coordinator.last_resources[COURSE]
assert cloudcourse.FIELD not in coordinator.resource(COURSE)
async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant):
"""Driven through async_send_command rather than write_fn directly: the
command path builds its own rep, and a rep taken straight off the state
cache carries no cloud programs, so the write would silently no-op."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
sent: list[tuple[list[str], bytes]] = []
class _FakeSession:
def post(self, path_segs, payload, timeout=None):
sent.append((path_segs, payload))
return 0x44, b""
def pace(self):
pass
coordinator._session = cast(Any, _FakeSession())
# Same shape as test_coordinator_send_command's fixture: a write schedules
# a debounced refresh, which would poll through this fake and leave its
# timer behind after the test.
coordinator.async_request_refresh = AsyncMock()
bound = next(b for b in coordinator.bound if b.desc.key == "cycle")
await coordinator.async_send_command(bound, "cloud:55")
assert len(sent) == 1
path_segs, payload = sent[0]
assert path_segs == ["course", "vs", "0"]
assert cbor2.loads(payload)["x.com.samsung.da.options"] == [
"Course_87",
f"OneTimeCloudCourse_{SPORTS}",
]
# The optimistic merge keeps the rest of the array intact for the settle
# window -- including the device's own slot list and the saved default.
merged = coordinator.resource(COURSE)["x.com.samsung.da.options"]
assert "Course_87" in merged
assert f"OneTimeCloudCourse_{SPORTS}" in merged
assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged
async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
issue_id = f"cloud_courses_{entry.entry_id}"
issue = registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert (issue.translation_placeholders or {})["total"] == "9"
for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()):
# Only two are learned; name every advertised slot to close the gap.
coordinator.cloud_courses.observe(
{
"x.com.samsung.da.options": [
"CloudExtraCourse_0A5C286B2D0C55301A",
f"CloudCourse_0000{slot}0449284D134AA04C0035F004F005F0AC00",
]
}
)
coordinator.apply_cloud_courses({slot: f"Program {slot}"}, "87")
await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is None
async def test_no_repair_for_a_device_without_downloaded_programs(hass: HomeAssistant):
entry = _entry(hass)
coordinator = LocalThingsCoordinator(hass, entry)
resources = _load_device("washer")
coordinator._run_discovery(resources)
for href, rep in resources.items():
coordinator._observe.apply(href, rep, source="poll")
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant):
entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}})
coordinator = await _coordinator(hass, entry)
# Dropped on restore, then relearned from the live poll.
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
# ---------------------------------------------------------------------------
# Options flow
# ---------------------------------------------------------------------------
async def _options_handler(hass: HomeAssistant, coordinator: LocalThingsCoordinator):
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
entry = coordinator.config_entry
assert entry is not None
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
handler = LocalThingsOptionsFlow()
handler.hass = hass
# OptionsFlow.config_entry resolves through `handler`, which the flow
# manager normally sets when it starts the flow.
handler.handler = entry.entry_id
return handler
async def test_the_menu_offers_download_cycles_only_when_the_device_has_them(
hass: HomeAssistant,
):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_init()
assert "cloud_courses" in result["menu_options"]
async def test_naming_through_the_flow_persists(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_manual()
await handler.async_step_cloud_manual(
{"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"}
)
await _flush(hass)
assert coordinator.cloud_courses.named() == {"55": "Sports", "6B": "Jeans"}
assert entry.data[CONF_CLOUD_COURSES]["download_course"] == "87"
async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_manual(
{"name_55": "Sports", "name_6B": "sports", "download_course": "87"}
)
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
assert coordinator.cloud_courses.named() == {}
async def test_clearing_a_name_removes_the_program_from_the_select(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "87"})
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
await handler.async_step_cloud_manual({"name_55": "", "download_course": "87"})
await _flush(hass)
assert "cloud:55" not in _cycle_options(coordinator)
async def test_without_a_confirmed_download_course_nothing_is_offered(hass: HomeAssistant):
"""Names alone aren't enough -- there'd be no course code to write."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": ""})
await _flush(hass)
assert coordinator.cloud_courses.named() == {"55": "Sports"}
assert "cloud:55" not in _cycle_options(coordinator)
async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_manual()
assert result["step_id"] == "cloud_manual"
assert result["description_placeholders"]["total"] == "9"
# Two learned so far, seven still to walk through on the appliance.
assert result["description_placeholders"]["found"] == "2"
_observe_program_load(coordinator, SPORTS)
assert coordinator.cloud_courses.download_candidates() == ["87"]
async def test_no_repair_until_a_program_has_actually_been_seen(hass: HomeAssistant):
"""The DW5000C advertises four downloaded programs and has never loaded
one, so nothing about them is learnable and no name field can be offered.
Warning its owner about a feature they may never use -- with no action
that could clear it -- is worse than staying quiet until they run one."""
entry = _entry(hass)
coordinator = LocalThingsCoordinator(hass, entry)
resources = _load_device("dishwasher_dw5000c_cloud")
coordinator._run_discovery(resources)
for href, rep in resources.items():
coordinator._observe.apply(href, rep, source="poll")
coordinator._refresh_cloud_course_issue()
await _flush(hass)
assert cloudcourse.advertised_slots(coordinator.cloud_course_rep()) == ["8E", "8D", "8F", "02"]
assert coordinator.cloud_courses.snapshot()["slots"] == {}
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
async def test_the_synthetic_field_never_reaches_diagnostics(
hass: HomeAssistant,
enable_custom_integrations,
):
"""canonical_resources carries it so entity descriptors can read it, and
diagnostics reads canonical_resources -- so the drop has to happen at the
redaction boundary. A dump is meant to be what the appliance said, and
cloud program names are text the user typed."""
from custom_components.localthings.diagnostics import (
async_get_config_entry_diagnostics,
)
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator.apply_cloud_courses({"55": "Marc's weekend towels"}, "87")
await _flush(hass)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, entry)
assert cloudcourse.FIELD not in json.dumps(diag["resources"])
# The device's own tokens are still there -- resources stays what it said.
assert "CloudExtraCourse_0A5C286B2D0C55301A" in json.dumps(diag["resources"])
# The store is reported in full in its own block: what was discovered,
# what the user named it, and which course they confirmed. Half of what
# goes wrong here is a configuration question and none of it is
# answerable from the payloads alone.
cloud = diag["cloud_courses"]
assert cloud["advertised_slots"] == ["0A", "5C", "28", "6B", "2D", "0C", "55", "30", "1A"]
assert cloud["download_course"] == "87"
assert cloud["slots"]["55"] == {"blob": SPORTS, "name": "Marc's weekend towels"}
# A slot seen but never named is reported too -- that gap is the thing a
# "why isn't my download cycle showing up" report needs to reveal.
assert cloud["slots"]["6B"] == {"blob": JEANS, "name": ""}
async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant):
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
stripped = coordinator.device_resources(MAIN)
assert cloudcourse.FIELD not in stripped[COURSE]
assert "x.com.samsung.da.options" in stripped[COURSE]
async def test_the_flow_rejects_a_download_course_the_device_does_not_offer(
hass: HomeAssistant,
):
"""Whatever lands here becomes the Course_ token of a real write."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "FF"})
assert result["errors"] == {"base": "cloud_course_unknown_course"}
assert coordinator.cloud_courses.snapshot()["download_course"] is None
assert coordinator.cloud_courses.named() == {}
async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAssistant):
"""Personal course names come from the device, not the catalog, and the
select renders them the same way -- so they collide the same way."""
coordinator = await _coordinator(hass)
# 'MyCo' as a personal course label on a code the device offers.
rep = dict(coordinator.resource("/wm/personalcourse/vs/0") or {})
rep["x.com.samsung.da.courses"] = ["1C_01044D79436F"]
coordinator._observe.apply("/wm/personalcourse/vs/0", rep, source="poll")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_manual({"name_55": "MyCo", "download_course": "87"})
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
# ---------------------------------------------------------------------------
# Guided setup
# ---------------------------------------------------------------------------
def _observe_program_load(coordinator, blob, course="87"):
"""Stand in for the user loading a program while Home Assistant watches.
A candidate Download course comes only from a transition that was
actually observed, so tests that expect one have to produce it."""
coordinator._observe.apply(
COURSE,
{
"x.com.samsung.da.options": [
"CloudExtraCourse_0A5C286B2D0C55301A",
f"Course_{course}",
f"OneTimeCloudCourse_{blob}",
]
},
source="poll",
)
def _stub_probe(coordinator, sequence):
"""Drive async_probe_cloud_courses from a scripted list of loaded slots,
standing in for the user turning the dial. The last entry repeats."""
seq = list(sequence)
async def probe() -> str | None:
return seq.pop(0) if len(seq) > 1 else seq[0]
# setattr, not assignment: the stub stands in for a bound method.
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
async def _run_wait(handler):
"""Re-enter the progress step the way Home Assistant does until its task
settles, then follow the completion through."""
result = await handler.async_step_cloud_wait()
for _ in range(200):
if result["type"] != "progress":
return result
if handler._cloud_task is not None:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(asyncio.shield(handler._cloud_task), 5)
result = await handler.async_step_cloud_wait()
raise AssertionError("progress step never settled")
async def test_guided_waits_for_a_change_not_a_state(hass: HomeAssistant):
"""The trap this design exists to avoid. After naming a program the
appliance is still sitting on it, so a loop that fires on "a known slot is
loaded" would re-offer the same one forever. Each round baselines on what
is loaded when it starts and completes only on a change."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
# The fixture is already loaded with 6B, so that is the baseline.
await handler.async_step_cloud_guided()
assert handler._cloud_baseline == "6B"
# The appliance stays on 6B: no round completes.
_stub_probe(coordinator, ["6B"])
handler._cloud_task = None
task = hass.async_create_task(handler._await_cloud_selection(coordinator))
await asyncio.sleep(0)
assert not task.done()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def test_guided_names_a_newly_selected_program(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
result = await _run_wait(handler)
assert result["step_id"] == "cloud_name"
form = await handler.async_step_cloud_name()
assert form["description_placeholders"]["slot"] == "55"
# Persisted immediately -- closing the dialog now would lose nothing.
await handler.async_step_cloud_name({"name": "Sports"})
await _flush(hass)
assert coordinator.cloud_courses.named() == {"55": "Sports"}
async def test_guided_rebaselines_so_the_named_program_cannot_refire(
hass: HomeAssistant,
):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
await handler.async_step_cloud_name({"name": "Sports"})
await _flush(hass)
# The next round starts from the program just named, so the appliance
# sitting on it is not a new selection.
assert handler._cloud_baseline == "55"
async def test_reselecting_a_named_program_offers_an_edit_not_an_error(
hass: HomeAssistant,
):
"""Re-picking one is how someone checks their work. It gets the existing
name pre-filled, and the counter deliberately does not move."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
form = await handler.async_step_cloud_name()
assert form["errors"] == {}
assert form["data_schema"]({})["name"] == "Sports"
before = form["description_placeholders"]["named"]
await handler.async_step_cloud_name({"name": "Sports"})
await _flush(hass)
after = handler._cloud_progress_placeholders(coordinator)["named"]
assert before == after == "1"
async def test_guided_times_out_into_a_retry_or_finish_menu(hass: HomeAssistant, monkeypatch):
from custom_components.localthings import config_flow as cf
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.0)
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["6B"])
result = await _run_wait(handler)
assert result["step_id"] == "cloud_timeout"
menu = await handler.async_step_cloud_timeout()
assert set(menu["menu_options"]) == {"cloud_guided", "cloud_finish"}
async def test_a_failed_probe_does_not_end_the_round(hass: HomeAssistant):
"""A dropped read is one missed poll, not a reason to bail on someone
standing at the appliance."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, [None, None, "55"])
result = await _run_wait(handler)
assert result["step_id"] == "cloud_name"
assert handler._cloud_slot == "55"
async def test_the_entry_step_is_a_menu_offering_both_paths(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_courses()
assert set(result["menu_options"]) == {"cloud_guided", "cloud_manual"}
async def test_closing_the_dialog_stops_probing_the_appliance(hass: HomeAssistant):
"""Closing the dialog is the documented way to leave guided setup, so it
has to actually stop -- an abandoned round would otherwise keep taking the
session lock every few seconds for a user who has walked away."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
probes = 0
async def probe() -> str | None:
nonlocal probes
probes += 1
return "6B" # never changes, so the round would run to timeout
# setattr, not assignment: the stub stands in for a bound method.
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
result = await handler.async_step_cloud_wait()
assert result["type"] == "progress"
task = handler._cloud_task
assert task is not None and not task.done()
handler.async_remove()
with contextlib.suppress(asyncio.CancelledError):
await task
assert task.cancelled()
settled = probes
await asyncio.sleep(0.05)
assert probes == settled
async def test_guided_lists_the_names_assigned_so_far(hass: HomeAssistant):
"""The only orientation available on a long walk -- the programs still to
do are unnamed by definition, so 'done so far' is all there is to show.
It doubles as duplicate avoidance, since the form rejects a repeat."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
assert handler._cloud_progress_placeholders(coordinator)["named_list"] == "none yet"
coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87")
await _flush(hass)
placeholders = handler._cloud_progress_placeholders(coordinator)
assert placeholders["named"] == "2"
assert placeholders["total"] == "9"
# The appliance's own advertised order, not naming order: 6B precedes 55
# in CloudExtraCourse_0A5C286B2D0C55301A.
assert placeholders["named_list"] == "Jeans, Sports"
async def test_the_name_form_shows_the_other_names_while_typing(hass: HomeAssistant):
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
form = await handler.async_step_cloud_name()
assert form["description_placeholders"]["named_list"] == "Jeans"
async def test_guided_naming_never_clears_a_confirmed_download_course(hass: HomeAssistant):
"""A program needs both a name and the Download course before it can be
offered. The guided name form says nothing about the course, and passing
that silence through as None wiped a confirmed one -- so naming a program
removed every already-named program from the cycle list."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
await _flush(hass)
assert "cloud:6B" in _cycle_options(coordinator)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
await handler.async_step_cloud_name({"name": "Sports"})
await _flush(hass)
assert coordinator.cloud_courses.snapshot()["download_course"] == "87"
assert {"cloud:55", "cloud:6B"} <= set(_cycle_options(coordinator))
async def test_guided_setup_alone_produces_a_selectable_cycle(hass: HomeAssistant):
"""The walk has to stand on its own: someone who only ever uses guided
setup must end up with something in the cycle list. So the first name
form also asks for the Download course, prefilled from what was just
observed, and drops the field once it is confirmed."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
first = await handler.async_step_cloud_name()
assert "download_course" in str(first["data_schema"].schema)
# Prefilled once a load has actually been watched -- in real use the
# guided probe loop feeds observe() and produces exactly this.
_observe_program_load(coordinator, SPORTS)
assert handler._cloud_course == "87"
await handler.async_step_cloud_name({"name": "Sports", "download_course": "87"})
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
# Confirmed now, so the next program is asked for its name only.
handler._cloud_slot = "6B"
second = await handler.async_step_cloud_name()
assert "download_course" not in str(second["data_schema"].schema)
async def test_guided_rejects_a_download_course_the_device_does_not_offer(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
result = await handler.async_step_cloud_name({"name": "Sports", "download_course": "FF"})
assert result["errors"] == {"base": "cloud_course_unknown_course"}
assert coordinator.cloud_courses.snapshot()["download_course"] is None
async def test_guided_rejects_a_name_another_program_already_has(hass: HomeAssistant):
"""Guided setup submits one program at a time, so a within-form check
can't see the others. Two programs sharing a label resolve to whichever
option comes first, meaning picking the second would run the first one's
payload."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"6B": "Sports"}, "87")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
result = await handler.async_step_cloud_name({"name": "Sports"})
await _flush(hass)
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
assert coordinator.cloud_courses.named() == {"6B": "Sports"}
async def test_renaming_a_program_to_its_own_name_is_allowed(hass: HomeAssistant):
"""The slot being edited is excluded from the taken set, or confirming an
unchanged name would reject itself."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
_stub_probe(coordinator, ["55"])
await _run_wait(handler)
result = await handler.async_step_cloud_name({"name": "Sports"})
assert result["type"] == "progress" # accepted, straight back to waiting
async def test_the_timeout_screen_fills_in_its_counters(hass: HomeAssistant):
"""Its copy interpolates the same placeholders as the other screens, so
without them the dialog renders literal braces."""
coordinator = await _coordinator(hass)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_timeout()
assert result["description_placeholders"]["named"] == "1"
assert result["description_placeholders"]["named_list"] == "Sports"
async def test_guided_ignores_a_payload_the_store_would_not_record(hass: HomeAssistant):
"""The probe reports whatever payload is loaded; observe() declines one
whose slot the device doesn't advertise. Offering to name that would take
a name and discard it -- there is no record to hang it on."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_guided()
# 'ZZ' is not in this appliance's CloudExtraCourse_ list.
_stub_probe(coordinator, ["ZZ"])
result = await _run_wait(handler)
assert result["step_id"] == "cloud_timeout"
async def test_the_prefilled_course_is_always_one_the_dropdown_offers(hass: HomeAssistant):
"""An observed candidate the appliance's course list no longer contains
would prefill a value the selector rejects, failing validation on
something the user never chose."""
coordinator = await _coordinator(hass)
coordinator.cloud_courses._candidates["FF"] = 99 # not in the course list
handler = await _options_handler(hass, coordinator)
offered = handler._cloud_course_selector(coordinator).config["options"]
assert handler._cloud_course is None or handler._cloud_course in offered
+3 -2
View File
@@ -5,7 +5,7 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
check the dishwasher wiring and its device-specific options.
"""
from custom_components.localthings.registry.capabilities import dishwasher, laundry
from custom_components.localthings.registry.capabilities import dishwasher
from custom_components.localthings.registry.entities import SwitchDesc
@@ -15,7 +15,8 @@ class TestCycleOptions:
def test_cycle_desc_uses_shared_cycle_options(self):
desc = self._cycle()
assert desc.options is laundry.cycle_options
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E82"}}
assert desc.options(live) == ["0E", "82"]
assert desc.translation_key == "dishwasher_cycle"
def test_exists_only_when_edit_course_list_is_live(self):
+3 -2
View File
@@ -2,7 +2,7 @@
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
from custom_components.localthings.registry.capabilities import dryer, ignored
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device
@@ -82,7 +82,8 @@ def test_course_bound_to_shared_course_vs_0():
table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}}
assert desc.translation_key(table_03) == "dryer_cycle_table_03"
assert desc.translation_key({}) == "cycle"
assert desc.options is laundry.cycle_options
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1620"}}
assert desc.options(live) == ["16", "20"]
rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]}
assert desc.rep_fn is not None
assert desc.rep_fn(rep) == "16"
+43
View File
@@ -1496,3 +1496,46 @@ def test_resources_from_batch_preferred_over_flat():
}
result = _resources_from_dump(dump)
assert result == {"/foo": {"x": 1}}
def test_registry_reproduces_golden_state_keys_for_washer_ww5000c_cloud():
"""The issue #342 reporter's WW5000C (DA_WM_TP1_21_COMMON, Table_02),
captured while sitting on its Download course with a one-time Jeans
program loaded over a saved Sports default.
The cloud "Download" programs it advertises add no entity of their own
-- they ride in the existing cycle select -- so this golden is the guard
that discovering them never grows the entity set.
Its /wm/editcourse/vs/0 is empty, so its course list comes from the
supportedOptions fallback (issue #1)."""
from tests.conftest import _load_device
resources = _load_device("washer_ww5000c_cloud")
golden = json.loads((GOLDEN / "washer_ww5000c_cloud.json").read_text())
state_keys = _new_state_keys("washer_ww5000c_cloud", resources)
assert set(state_keys) == set(golden["state_keys"]), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_dishwasher_dw5000c_cloud():
"""A DW5000C dishwasher (issues #113/#123) that advertises four
downloaded programs it has never loaded -- CloudExtraCourse_ with no
CloudCourse_/OneTimeCloudCourse_ payload alongside it (issue #342).
Proves the cloud-cycle machinery is not washer-only and, more usefully,
that a device can advertise programs whose payloads have never been
seen. It adds no entity of its own either way."""
from tests.conftest import _load_device
resources = _load_device("dishwasher_dw5000c_cloud")
golden = json.loads((GOLDEN / "dishwasher_dw5000c_cloud.json").read_text())
state_keys = _new_state_keys("dishwasher_dw5000c_cloud", resources)
assert set(state_keys) == set(golden["state_keys"]), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
+4 -1
View File
@@ -233,7 +233,10 @@ class TestCycleSelect:
assert desc.key == "cycle"
assert desc.translation_key == "dryer_cycle"
assert desc.icon == "mdi:tumble-dryer"
assert desc.options is laundry.cycle_options
# Behavior, not identity: the option list is the device's own live
# course list (plus any named cloud programs -- see cycle_select).
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_161C"}}
assert desc.options(live) == ["16", "1C"]
def test_reads_raw_course_code_from_options(self):
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
+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"
+15 -3
View File
@@ -7,7 +7,7 @@ fallback pairs and the energy meter in test_common_capabilities.py.
from datetime import UTC
from custom_components.localthings.registry.capabilities import laundry, washer
from custom_components.localthings.registry.capabilities import washer
from custom_components.localthings.registry.entities import SelectDesc
@@ -157,8 +157,20 @@ class TestWasherCourse:
for e in washer.WASHER_COURSE.entities
if e.key == "cycle" and isinstance(e, SelectDesc)
)
assert desc.options is laundry.cycle_options
assert desc.display_fn is laundry.washer_cycle_fallback
# Behavior, not identity: cycle_select wraps both hooks to fold in
# cloud "Download" programs (issue #342), so the plain functions are
# no longer handed through as-is.
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}}
assert desc.options(live) == ["1C", "1D"]
# display_fn still resolves a personal course name through
# washer_cycle_fallback for a non-cloud value.
assert desc.display_fn is not None
personal = {
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": ["A1_01044D79436F"],
}
}
assert desc.display_fn("A1", personal) == "MyCo"
def test_exists_only_when_edit_course_list_is_live(self):
"""No hardcoded course table is kept -- the selector only appears