laundry: discover and offer cloud "Download" cycles (issue #342)

A washer whose course table includes "Download"/"Downloaded" runs whichever
program the SmartThings cloud last pushed down. Those programs are now
selectable from the ordinary cycle select, so a downloaded Jeans or Sports
cycle can be started without giving the appliance internet access.

The device turns out to enumerate them itself. `CloudExtraCourse_` on
/course/vs/0 lists one byte per downloaded program, and byte 2 of a
program's payload is exactly that slot id -- verified against all nine
programs on the reporter's WW5000C and against the WA55A7700AV dump already
in the corpus. So nothing here is hardcoded: the appliance says which
programs exist, the payloads are learned by watching what it reports, and
the names come from the user.

That last part is unavoidable rather than a shortcut. A payload is only
visible while its program is loaded, and the appliance never reports a name
for one. So cloudcourse.py persists what has been seen (same rationale as
learned.py's mode store), a Repairs issue tells the owner how many programs
are still unaccounted for, and an options-flow step collects the names. A
program appears in the cycle select only once it is both learned and named.

Selecting one issues the only two-token options write in the codebase --
the course token has to switch to Download in the same write, or the
appliance accepts the program token and silently ignores it (confirmed on
hardware). The Download course code is learned by observation but never
applied until the user confirms it: tokens in this array are replaced by
prefix and never evicted, so a stale program token can appear alongside an
unrelated course, and acting on that would start the wrong wash cycle. For
the same reason a stale token is never reported as the running program.

Also of note:

- There is no single "Download" course code. The WW5000C uses 87, the
  WA55A7700AV uses 17 -- same Table_02. Any per-table lookup would have
  been wrong on one of the only two devices available to check.
- Payloads are replayed byte-for-byte and never decomposed or rebuilt.
  Bytes 5/7/9 do decode to temperature/rinse/spin on the WW5000C, 9 for 9,
  and produce nonsense on the WA55A7700AV -- so that decode is written up
  in docs/investigations/download-cycle.md and not shipped, and the
  read-only sensors it would have enabled were dropped.
- The store reaches the registry as a namespaced synthetic field merged
  onto /course/vs/0's rep at read time, so exists_fn/rep_fn/options/write_fn
  all see it through their existing signatures. It never enters the state
  cache, so it can't be polled over, written to the device, or land in a
  diagnostics dump.
- A name that would render identically to another cycle in the same
  dropdown is rejected in the flow: the select maps a chosen label back to
  a raw value by matching display text.

Non-English catalogs carry the new strings in English for now; they need
real translations.
This commit is contained in:
Marc Billow committed 2026-08-09 21:21:10 +00:00
1 parent 1424c2222e
commit f45bd6a72c
23 files changed
+2162 -32

No files matched your search

+18
View File
@@ -48,3 +48,21 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get("state", ())) if entry else frozenset()
def translated_state_labels(platform: str, translation_key: str) -> dict[str, str]:
"""`state key -> English label` for `platform`.`translation_key`.
The labels behind translated_states, for the one caller that has to
compare against what a user actually reads rather than which codes are
translated: the download-cycle naming step rejects a name that would be
indistinguishable from a local course in the same dropdown. English only,
matching this catalog -- a name unique here can still collide in another
locale, which the select's local-courses-first ordering resolves toward
the local course.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
states = entry.get("state") if entry else None
if not isinstance(states, dict):
return {}
return {code: label for code, label in states.items() if isinstance(label, str)}
@@ -0,0 +1,312 @@
"""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.
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
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"
# 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 [blob[i : i + 2].upper() for i in range(0, len(blob), 2)]
def is_loaded(blob) -> bool:
"""True when `blob` names an actual program rather than 'none'."""
parts = _hex_bytes(blob)
return bool(parts) and not blob.upper().startswith(_SENTINEL_PREFIX)
def slot_of(blob) -> str | None:
"""The slot id `blob` belongs to, or None if it names no program."""
parts = _hex_bytes(blob)
if not parts or not is_loaded(blob):
return None
return parts[_SLOT_BYTE]
def option_value(options, prefix):
"""`<prefix>_<value>` from an options[] array. Duplicated from
laundry.option_value rather than imported: this module is imported by
the coordinator, and reaching into registry.capabilities from there
would invert the dependency direction the rest of the integration
keeps."""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
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 []
slots = [raw[i : i + 2].upper() for i in range(0, len(raw), 2)]
# 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(slots))
def supports_cloud_courses(rep) -> bool:
"""True for a device that advertises any downloaded-program slot."""
return bool(advertised_slots(rep))
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 stored(entry) -> dict:
"""What `entry` has persisted, coerced -- for a reader with no
coordinator to go through (the options flow, on an unloaded entry)."""
download, slots = _coerce(entry.data.get(CONF_CLOUD_COURSES))
return {"download_course": download, "slots": 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 while a one-time override was actually loaded --
# candidates for "which course means Download on this board", pending
# user confirmation (see download_candidates).
self._candidates: dict[str, int] = {}
# -- 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*: tokens in this array are
replaced by prefix and never evicted, so a stale
OneTimeCloudCourse_ can outlive the run it belonged to and be
reported alongside an unrelated local course. Confirming the code is
the user's call in the options flow -- a wrong one would write a real
wash cycle when someone picked a download program.
"""
options = rep.get("x.com.samsung.da.options")
if not options:
return False
known_slots = advertised_slots(rep)
changed = False
with self._lock:
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": ""}
changed = True
elif record["blob"] != blob.upper():
record["blob"] = blob.upper()
changed = True
oneshot = option_value(options, ONESHOT_PREFIX)
course = option_value(options, COURSE_PREFIX)
if course and is_loaded(oneshot):
self._candidates[course] = self._candidates.get(course, 0) + 1
return changed
# -- reads ------------------------------------------------------------
def download_course(self) -> str | None:
with self._lock:
return self._download_course
def download_candidates(self) -> list[str]:
"""Course codes seen alongside a loaded one-time override, most
frequent first -- what the options flow offers as the likely Download
course. Never used for a write on its own."""
with self._lock:
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
return [code for code, _ in ranked]
def blob(self, slot: str) -> str | None:
with self._lock:
record = self._slots.get(slot.upper())
return record["blob"] if record else None
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 record["name"]
},
}
# -- 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()
def clear(self) -> None:
with self._lock:
self._download_course = None
self._slots = {}
self._candidates = {}
def undiscovered(rep: dict, record: dict) -> list[str]:
"""Advertised 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."""
programs = record.get("slots") or {}
return [slot for slot in advertised_slots(rep) if not (programs.get(slot) or {}).get("name")]
+137 -4
View File
@@ -34,6 +34,8 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from . import cloudcourse
from .catalog import translated_state_labels
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
from .registry.subdevices import MAIN
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
@@ -832,10 +836,14 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
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()):
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 +909,131 @@ 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:
"""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.advertised_slots(rep)
# 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, rep, known, advertised, errors=errors)
return self._cloud_courses_form(coord, rep, known, advertised)
def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]:
"""Validate and store the submitted names + Download course code.
A name that collides with any other entry in the cycle select --
another download cycle, or one of the appliance's own local course
names -- is rejected rather than silently accepted. The select maps a
chosen label back to a raw value by matching display text, so two
options sharing a label would resolve to whichever comes first.
"""
names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known}
taken = {name.casefold() for name in self._local_course_names(coord)}
for name in names.values():
if not name:
continue
if name.casefold() in taken:
return {"base": "cloud_course_name_duplicate"}
taken.add(name.casefold())
for slot, name in names.items():
coord.cloud_courses.set_name(slot, name)
coord.set_cloud_download_course(user_input.get("download_course") or None)
return {}
def _local_course_names(self, coord) -> set[str]:
"""Translated display names of this appliance's own local courses.
Read through the same catalog the select renders from, so the check
matches what the user will actually see side by side in the dropdown.
An untranslated code has no display name to collide with.
"""
bound = next(
(b for b in coord.bound if b.desc.key == "cycle" and b.href == cloudcourse.COURSE_HREF),
None,
)
if bound is None:
return set()
resources = coord.canonical_resources(bound.subdevice)
key = bound.desc.translation_key
if callable(key):
key = key(resources)
labels = translated_state_labels("select", key) if key else {}
return {labels[code.lower()] for code in cycle_options(resources) if code.lower() in labels}
def _cloud_courses_form(
self, coord, rep, 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
)
# Course codes this device actually offers, so the Download course
# can only ever be set to one of them. Auto-detected candidates come
# first -- see CloudCourses.download_candidates.
available = cycle_options(coord.canonical_resources(MAIN))
candidates = [c for c in store.download_candidates() if c in available]
ordered = candidates + [c for c in available if c not in candidates]
suggested = record["download_course"] or (candidates[0] if candidates else None)
fields[vol.Optional("download_course", description={"suggested_value": suggested})] = (
SelectSelector(
SelectSelectorConfig(
options=ordered,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)
)
)
pending = [s for s in advertised if s not in slots]
return self.async_show_form(
step_id="cloud_courses",
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
+132 -7
View File
@@ -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,
@@ -259,6 +263,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 +319,29 @@ 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, written to the device, or
reach a diagnostics dump -- `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.
"""
snapshot = self._cache.snapshot()
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 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 +355,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 +419,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 +440,85 @@ 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 the options flow."""
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)
def set_cloud_course_name(self, slot: str, name: str) -> None:
self._cloud.set_name(slot, name)
self._persist_cloud_courses()
def set_cloud_download_course(self, code: str | None) -> None:
self._cloud.set_download_course(code)
self._persist_cloud_courses()
def forget_cloud_courses(self) -> None:
self._cloud.clear()
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.
"""
issue_id = f"cloud_courses_{self._entry.entry_id}"
rep = self.cloud_course_rep()
pending = cloudcourse.undiscovered(rep, self._cloud.snapshot())
needs_course = bool(cloudcourse.advertised_slots(rep)) and not self._cloud.download_course()
if pending or needs_course:
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(cloudcourse.advertised_slots(rep))),
},
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 +621,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 +1183,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 +1212,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 +1260,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 +1284,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 +1306,11 @@ 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_resources(), 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.
rep = self.entity_resources().get(href or "") 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
@@ -23,6 +23,7 @@ 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
@@ -309,20 +310,120 @@ 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))
def cycle_write(p, rep, href=None):
# ---------------------------------------------------------------------------
# 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 dial, "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.
#
# 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, resources=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 +500,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 +516,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("/course/vs/0") 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,
)
@@ -1406,6 +1406,7 @@
"title": "Možnosti LocalThings",
"menu_options": {
"settings": "Nastavení zápisu pro dálkové ovládání",
"cloud_courses": "Download cycles",
"forget_learned_modes": "Zapomenout zapamatované režimy",
"debug_write": "Ladění: zápis do prostředku"
}
@@ -1444,11 +1445,19 @@
"debug_write": "Zapsat do dalšího prostředku",
"finish": "Dokončit"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
@@ -1458,6 +1467,10 @@
"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": "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": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings-Optionen",
"menu_options": {
"settings": "Geräteeinstellungen",
"cloud_courses": "Download cycles",
"debug_write": "Debug: In eine Ressource schreiben",
"forget_learned_modes": "Gemerkte Modi vergessen"
}
@@ -1444,11 +1445,19 @@
"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_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
@@ -1458,6 +1467,10 @@
"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": "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": {
@@ -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,11 +1445,19 @@
"debug_write": "Write another resource",
"finish": "Finish"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
@@ -1458,6 +1467,10 @@
"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": "Download cycles",
"forget_learned_modes": "Olvidar los modos recordados",
"debug_write": "Depuración: escribir en un recurso"
}
@@ -91,11 +92,19 @@
"debug_write": "Escribir otro recurso",
"finish": "Finalizar"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
@@ -105,6 +114,10 @@
"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": "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": {
@@ -1406,6 +1406,7 @@
"title": "Opzioni LocalThings",
"menu_options": {
"settings": "Impostazioni dispositivo",
"cloud_courses": "Download cycles",
"forget_learned_modes": "Dimentica le modalità memorizzate",
"debug_write": "Debug: scrivi su una risorsa"
}
@@ -1444,11 +1445,19 @@
"debug_write": "Scrivi un'altra risorsa",
"finish": "Fine"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
@@ -1458,6 +1467,10 @@
"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": "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": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings 옵션",
"menu_options": {
"settings": "기기 설정",
"cloud_courses": "Download cycles",
"forget_learned_modes": "기억된 모드 지우기",
"debug_write": "디버그: 리소스에 쓰기"
}
@@ -1444,11 +1445,19 @@
"debug_write": "다른 리소스에 쓰기",
"finish": "완료"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"error": {
"empty_payload": "쓸 필드를 하나 이상 입력하세요.",
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요."
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.",
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
@@ -1458,6 +1467,10 @@
"device_gap": {
"title": "{device_name}의 기능 지원이 완전하지 않음",
"description": "이 기기의 일부 기능이 아직 완전히 지원되지 않습니다. 가전제품 유형이 인식되지 않았거나, 기기가 제공하는 일부 리소스가 아직 구현되지 않았습니다. 현재 지원되는 기능은 계속 사용할 수 있습니다. 설정 > 기기 및 서비스 > {device_name} > 오른쪽 위 메뉴 > 진단 정보 다운로드로 이동하여 진단 정보를 내려받은 뒤, 연결된 이슈 양식에 첨부하면 지원 범위를 넓히는 데 도움을 줄 수 있습니다."
},
"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": {
@@ -1406,6 +1406,7 @@
"title": "LocalThings-opties",
"menu_options": {
"settings": "Apparaatinstellingen",
"cloud_courses": "Download cycles",
"forget_learned_modes": "Onthouden modi vergeten",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
@@ -1444,11 +1445,19 @@
"debug_write": "Naar een andere resource schrijven",
"finish": "Voltooien"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Your washer reports {total} downloaded cycle(s); {found} have been seen so far.\n\nThe appliance only reveals a downloaded cycle's settings while that cycle is actually loaded, and it never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle and turn the dial through each downloaded program, pausing a few seconds on each, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\n\"Download cycle\" is the course on your appliance that runs a downloaded program -- it is detected automatically, but confirm it here before use, since selecting a downloaded cycle writes this course code to the machine.",
"data": {
"download_course": "Download cycle course code"
}
}
},
"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": "Two cycles have the same name. Names must be unique."
},
"abort": {
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
@@ -1458,6 +1467,10 @@
"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": "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": {