laundry: apply cleanup review to the cloud-cycle branch

Four parallel reviews (reuse, simplification, efficiency, altitude). The two
that change behavior:

- observe() could report "changed" on every poll forever, rewriting the
  config entry each time. If both tokens name the same slot with different
  payloads -- a downloaded program with its settings tweaked for one run is
  exactly that shape -- each pass wrote the default's blob then the one-shot's
  over it, so neither was ever already stored. On the SD-card installs this
  integration runs on, sustained entry rewrites are the one cost here that
  bites. The end state is stable, so "changed" is now start-vs-end, not
  per-assignment.
- The write path copied every tracked href to read one rep, walking past the
  accessor added to avoid exactly that. New entity_rep() does the merge for a
  single href; cycle_write drops the resources parameter it never used.

Structure:

- device_resources() is a second accessor giving the pure device view, used
  by diagnostics and the debug read service. That deletes strip_synthetic,
  the _SYNTHETIC_KEY_PREFIX convention and the redact filter added last
  commit: "a dump is what the device said" is now which method you call
  rather than something every future exporter has to remember.
- apply_cloud_courses() is the single mutation path. The flow was reaching
  past the coordinator into the store and relying on a later call to persist
  and invalidate for it; nine names are also now one entry write, not nine.
- option_value/hex_pairs move to capabilities/common.py. The duplicate's
  stated reason -- that the coordinator shouldn't import from
  registry.capabilities -- was simply false; it already does, and so does
  learned.py. The real constraint is narrower: laundry.py imports
  cloudcourse, so the reverse would be a cycle.

Dropped rather than kept:

- The cloud-vs-translated-local-course name check, and catalog.
  translated_state_labels with it. The catalog this process can read is
  English while the dropdown is localized in the frontend, so it rejected
  "Cotton" for a German user seeing "Baumwolle" and missed the real collision
  when they typed "Baumwolle" -- wrong in both directions outside one locale,
  against an outcome option ordering already makes deterministic. The checks
  that survive compare strings that are the same in every locale: the user's
  own names, and the device's personal-course labels.
- stored(), clear()/forget_cloud_courses(), blob(), download_course() -- no
  production callers. stored() was a template artifact whose docstring
  described a caller that cannot exist here.

Diagnostics gains a cloud_courses block, which the store was missing next to
learned_modes -- payloads and which slots are named, but not the names
themselves, since those are the user's words and dumps get pasted publicly.

Kept against one reviewer's advice: option_tokens (two others called
generalizing option_write the right direction) and select._display's
uncatalogued branch, which names a condition the old fallback-is-None proxy
only got right by accident. Deferred: making the store per-subdevice. It is
MAIN-only today and no device seen advertises cloud programs elsewhere; the
limitation is now documented where it is made.
This commit is contained in:
Marc Billow
2026-08-10 03:30:23 +00:00
parent b921bdbb28
commit 2265c52c77
12 changed files with 220 additions and 247 deletions
-18
View File
@@ -48,21 +48,3 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
""" """
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key) entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get("state", ())) if entry else frozenset() 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)}
+39 -47
View File
@@ -52,6 +52,7 @@ from __future__ import annotations
import threading import threading
from .const import CONF_CLOUD_COURSES from .const import CONF_CLOUD_COURSES
from .registry.capabilities.common import hex_pairs, option_value
COURSE_HREF = "/course/vs/0" COURSE_HREF = "/course/vs/0"
@@ -94,33 +95,29 @@ def _hex_bytes(blob):
int(blob, 16) int(blob, 16)
except ValueError: except ValueError:
return [] return []
return [blob[i : i + 2].upper() for i in range(0, len(blob), 2)] return hex_pairs(blob.upper())
def is_loaded(blob) -> bool: def is_loaded(blob) -> bool:
"""True when `blob` names an actual program rather than 'none'.""" """True when `blob` names an actual program rather than 'none'."""
parts = _hex_bytes(blob) return _slot_and_loaded(blob)[1]
return bool(parts) and not blob.upper().startswith(_SENTINEL_PREFIX)
def slot_of(blob) -> str | None: def slot_of(blob) -> str | None:
"""The slot id `blob` belongs to, or None if it names no program.""" """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) parts = _hex_bytes(blob)
if not parts or not is_loaded(blob): if not parts:
return None return None, False
return parts[_SLOT_BYTE] if "".join(parts[:2]) == _SENTINEL_PREFIX:
return None, False
return parts[_SLOT_BYTE], True
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]: def advertised_slots(rep) -> list[str]:
@@ -131,10 +128,9 @@ def advertised_slots(rep) -> list[str]:
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX) raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
if not isinstance(raw, str) or len(raw) % 2: if not isinstance(raw, str) or len(raw) % 2:
return [] 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 # Preserve the device's own order (first-seen wins) while dropping any
# repeat, so the flow lists slots the way the appliance does. # repeat, so the flow lists slots the way the appliance does.
return list(dict.fromkeys(slots)) return list(dict.fromkeys(hex_pairs(raw.upper())))
def supports_cloud_courses(rep) -> bool: def supports_cloud_courses(rep) -> bool:
@@ -169,13 +165,6 @@ def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
return download, slots 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: def persist(hass, entry, record: dict) -> None:
"""Write `record` onto the entry. Runs on the event loop, which """Write `record` onto the entry. Runs on the event loop, which
async_update_entry requires.""" async_update_entry requires."""
@@ -230,8 +219,20 @@ class CloudCourses:
if not options: if not options:
return False return False
known_slots = advertised_slots(rep) known_slots = advertised_slots(rep)
changed = False oneshot = option_value(options, ONESHOT_PREFIX)
with self._lock: 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): for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
blob = option_value(options, prefix) blob = option_value(options, prefix)
slot = slot_of(blob) slot = slot_of(blob)
@@ -242,11 +243,10 @@ class CloudCourses:
record = self._slots.get(slot) record = self._slots.get(slot)
if record is None: if record is None:
self._slots[slot] = {"blob": blob.upper(), "name": ""} self._slots[slot] = {"blob": blob.upper(), "name": ""}
changed = True else:
elif record["blob"] != blob.upper():
record["blob"] = blob.upper() record["blob"] = blob.upper()
changed = True changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
oneshot = option_value(options, ONESHOT_PREFIX)
course = option_value(options, COURSE_PREFIX) course = option_value(options, COURSE_PREFIX)
if course and is_loaded(oneshot) and oneshot != self._last_oneshot: if course and is_loaded(oneshot) and oneshot != self._last_oneshot:
self._candidates[course] = self._candidates.get(course, 0) + 1 self._candidates[course] = self._candidates.get(course, 0) + 1
@@ -255,10 +255,6 @@ class CloudCourses:
# -- reads ------------------------------------------------------------ # -- reads ------------------------------------------------------------
def download_course(self) -> str | None:
with self._lock:
return self._download_course
def download_candidates(self) -> list[str]: def download_candidates(self) -> list[str]:
"""Course codes seen at the moment a one-time program was loaded, """Course codes seen at the moment a one-time program was loaded,
most-observed first -- what the options flow offers as the likely most-observed first -- what the options flow offers as the likely
@@ -267,11 +263,6 @@ class CloudCourses:
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0])) ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
return [code for code, _ in ranked] 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]: def named(self) -> dict[str, str]:
"""Slots that are both learned and named -- the only ones offerable """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 as a cycle option. An unnamed slot has no label that isn't either
@@ -299,7 +290,7 @@ class CloudCourses:
"programs": { "programs": {
slot: {"blob": record["blob"], "name": record["name"]} slot: {"blob": record["blob"], "name": record["name"]}
for slot, record in self._slots.items() for slot, record in self._slots.items()
if record["name"] if self._is_usable(record)
}, },
} }
@@ -315,11 +306,12 @@ class CloudCourses:
if record is not None: if record is not None:
record["name"] = name.strip() record["name"] = name.strip()
def clear(self) -> None: @staticmethod
with self._lock: def _is_usable(record) -> bool:
self._download_course = None """A slot is offerable once it has a name. The device supplies the
self._slots = {} payload; only the user can supply the label, so this is the whole
self._candidates = {} rule and it is stated once."""
return bool(record["name"])
def undiscovered(rep: dict, record: dict) -> list[str]: def undiscovered(rep: dict, record: dict) -> list[str]:
+28 -38
View File
@@ -35,7 +35,6 @@ from homeassistant.helpers.selector import (
) )
from . import cloudcourse from . import cloudcourse
from .catalog import translated_state_labels
from .const import ( from .const import (
CLIENTHELLO_PROBE_RETRIES, CLIENTHELLO_PROBE_RETRIES,
CLIENTHELLO_PROBE_TIMEOUT_S, CLIENTHELLO_PROBE_TIMEOUT_S,
@@ -945,21 +944,31 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
errors = self._apply_cloud_course_names(coord, known, user_input) errors = self._apply_cloud_course_names(coord, known, user_input)
if not errors: if not errors:
return self.async_create_entry(data=dict(self.config_entry.options)) 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, known, advertised, errors=errors)
return self._cloud_courses_form(coord, rep, known, advertised) return self._cloud_courses_form(coord, known, advertised)
def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]: def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]:
"""Validate and store the submitted names + Download course code. """Validate and store the submitted names + Download course code.
A name that collides with any other entry in the cycle select -- The select maps a chosen label back to a raw value by matching display
another download cycle, or one of the appliance's own local course text, so two options sharing a label resolve to whichever comes first.
names -- is rejected rather than silently accepted. The select maps a Two sources of collision are checkable here and both are rejected:
chosen label back to a raw value by matching display text, so two the user's own names against each other, and against the appliance's
options sharing a label would resolve to whichever comes first. 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} 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)} taken = {name.casefold() for name in self._device_course_names(coord)}
for name in names.values(): for name in names.values():
if not name: if not name:
continue continue
@@ -975,42 +984,23 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)): if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)):
return {"base": "cloud_course_unknown_course"} return {"base": "cloud_course_unknown_course"}
for slot, name in names.items(): coord.apply_cloud_courses(names, course)
coord.cloud_courses.set_name(slot, name)
coord.set_cloud_download_course(course)
return {} return {}
def _local_course_names(self, coord) -> set[str]: def _device_course_names(self, coord) -> set[str]:
"""Display names of this appliance's own local courses. """Course names this appliance reports itself.
Read through the same two sources the select renders from -- the Only the personal-course labels: the device sends these as text and
translation catalog, and the device's own personal-course labels the select renders them unchanged, so they are the same string in
(laundry.washer_cycle_fallback) -- so the check matches what the user every locale and can be compared against safely. See the caller for
will actually see side by side in the dropdown. A code neither source why translated course names are not included.
names has no display name to collide with.
""" """
bound = next( resources = coord.canonical_resources(MAIN)
(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 {}
personal = personal_course_labels(resources) personal = personal_course_labels(resources)
names = set() return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))}
for code in cycle_options(resources):
if (catalogued := labels.get(code.lower())) is not None:
names.add(catalogued)
if (own := personal.get(code.upper())) is not None:
names.add(own)
return names
def _cloud_courses_form( def _cloud_courses_form(
self, coord, rep, known, advertised, errors: dict[str, str] | None = None self, coord, known, advertised, errors: dict[str, str] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
store = coord.cloud_courses store = coord.cloud_courses
record = store.snapshot() record = store.snapshot()
+61 -21
View File
@@ -325,14 +325,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
onto /course/vs/0 under cloudcourse.FIELD (issue #342). onto /course/vs/0 under cloudcourse.FIELD (issue #342).
Merged at read time rather than applied to the state cache, so the 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 synthetic field can never be polled over or written to the device --
reach a diagnostics dump -- `last_resources` stays exactly what the `last_resources` stays exactly what the appliance reported. It rides
appliance reported. It rides on the rep instead of a resource of its on the rep instead of a resource of its own because rep_fn receives
own because rep_fn receives only its own href's rep: a sibling href only its own href's rep: a sibling href would be invisible to it, and
would be invisible to it, and /course/vs/0 is the one resource every /course/vs/0 is the one resource every consumer of this data is
consumer of this data is already bound to. 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._cache.snapshot() snapshot = self.last_resources
rep = snapshot.get(cloudcourse.COURSE_HREF) rep = snapshot.get(cloudcourse.COURSE_HREF)
if rep is None: if rep is None:
return snapshot return snapshot
@@ -342,6 +350,30 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view} snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view}
return snapshot 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]: def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s view of the live snapshot, rewritten to canonical """`subdevice`'s view of the live snapshot, rewritten to canonical
hrefs (issue #177, see subdevices.canonical_view). Any platform hrefs (issue #177, see subdevices.canonical_view). Any platform
@@ -466,23 +498,30 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@property @property
def cloud_courses(self) -> CloudCourses: def cloud_courses(self) -> CloudCourses:
"""The discovered-program store, for the options flow.""" """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 return self._cloud
def cloud_course_rep(self) -> dict: def cloud_course_rep(self) -> dict:
"""/course/vs/0's live rep -- what advertises the slot list.""" """/course/vs/0's live rep -- what advertises the slot list."""
return self.resource(cloudcourse.COURSE_HREF) return self.resource(cloudcourse.COURSE_HREF)
def set_cloud_course_name(self, slot: str, name: str) -> None: def apply_cloud_courses(self, names: dict[str, str], download_course: str | None) -> None:
self._cloud.set_name(slot, name) """The one mutation path for the cloud-program store (issue #342).
self._persist_cloud_courses()
def set_cloud_download_course(self, code: str | None) -> None: Takes the whole submission at once so a nine-program naming pass is
self._cloud.set_download_course(code) one config-entry write rather than nine, and so persistence, the
self._persist_cloud_courses() canonical-view invalidation and the Repairs refresh can't be done for
one half of a change and skipped for the other.
def forget_cloud_courses(self) -> None: """
self._cloud.clear() for slot, name in names.items():
self._cloud.set_name(slot, name)
self._cloud.set_download_course(download_course)
self._persist_cloud_courses() self._persist_cloud_courses()
@callback @callback
@@ -1316,11 +1355,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if write_fn is None: if write_fn is None:
return return
href = bound_entity.href href = bound_entity.href
# Through entity_resources(), not the bare cache: write_fn must see # Through entity_rep(), not the bare cache: write_fn must see the
# the same rep exists_fn/rep_fn were handed, including the merged # same rep exists_fn/rep_fn were handed, including the merged
# cloud-program field (issue #342). Identical to the cache entry for # cloud-program field (issue #342). Identical to the cache entry for
# every href that field doesn't touch. # every href that field doesn't touch, and without copying the whole
rep = self.entity_resources().get(href or "") or {} # tree to read one rep.
rep = self.entity_rep(href or "")
# The remote-control gate below keys off the raw on-the-wire href # The remote-control gate below keys off the raw on-the-wire href
# and a raw snapshot -- /remotectrl/* is a shared, MAIN-only # and a raw snapshot -- /remotectrl/* is a shared, MAIN-only
# resource that a subdevice's canonical_resources() view (owned # resource that a subdevice's canonical_resources() view (owned
+17 -2
View File
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.loader import async_get_integration from homeassistant.loader import async_get_integration
from . import cloudcourse
from .const import DOMAIN from .const import DOMAIN
from .coordinator import LocalThingsCoordinator from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources from .registry.redact import redact_resources
@@ -32,6 +33,7 @@ async def async_get_config_entry_diagnostics(
# disk (listdir + open + read_text), which trips HA's event-loop blocking # disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor. # detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local") 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 # /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` # below, so they'd otherwise never reach an issue report. /oic/d's `rt`
@@ -54,7 +56,7 @@ async def async_get_config_entry_diagnostics(
# than redacting /information/vs/0 again -- modelNum never matches # than redacting /information/vs/0 again -- modelNum never matches
# redact.py's substring rules, so the value is the same either way. # redact.py's substring rules, so the value is the same either way.
matching = [b for b in coordinator.bound if b.subdevice == su] 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 { return {
"kind": su.kind, "kind": su.kind,
"key": su.key, "key": su.key,
@@ -88,7 +90,7 @@ async def async_get_config_entry_diagnostics(
# /mode/vs/0 under no attribution. Each sibling reports its own # /mode/vs/0 under no attribution. Each sibling reports its own
# resources in `subdevices` below instead. For a device with no # resources in `subdevices` below instead. For a device with no
# subdevices, this is byte-identical to `last_resources`. # 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 # Sibling indoor subdevices discovered on this connection (issue
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes # #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# from) is deliberately NOT redacted here, unlike elsewhere in # from) is deliberately NOT redacted here, unlike elsewhere in
@@ -137,6 +139,19 @@ async def async_get_config_entry_diagnostics(
"enabled": coordinator.learning_enabled, "enabled": coordinator.learning_enabled,
"codes": coordinator.learned_snapshot(), "codes": coordinator.learned_snapshot(),
}, },
# Cloud "Download" programs discovered on this device (issue #342),
# reported separately for the same reason as learned_modes above.
# Payloads are the useful part for triage -- they are the only record
# of what a downloaded program contains. The names are not included:
# they are the user's own words, and a dump gets pasted into public
# issues. Which slots are named is still visible, which is all the
# triage question ("is this set up?") actually needs.
"cloud_courses": {
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
"download_course": cloud_courses["download_course"],
"payloads": {slot: rec["blob"] for slot, rec in cloud_courses["slots"].items()},
"named_slots": sorted(s for s, rec in cloud_courses["slots"].items() if rec["name"]),
},
"integration_version": integration.version, "integration_version": integration.version,
"smartthings_local_version": stl_version, "smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode, "observe_mode": coordinator.observe_mode,
@@ -136,6 +136,28 @@ def _active_alarm_codes(items):
return ", ".join(codes) if codes else "none" 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): def merge_options_field(cached, new_tokens):
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached """Merge freshly-written `<Prefix>_<Value>` tokens into a cached
x.com.samsung.da.options[]-style array the same way the device itself x.com.samsung.da.options[]-style array the same way the device itself
@@ -27,6 +27,7 @@ from ... import cloudcourse
from ...catalog import has_entity_translation from ...catalog import has_entity_translation
from ..capability import Capability from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
from .common import hex_pairs, option_value
_LED_LEVELS = ("Low", "High") _LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute") _SOUND_MODES = ("voice", "tone", "mute")
@@ -199,11 +200,6 @@ BUZZER_SOUND = Capability(
# boards expose the same /course/vs/0 options contract. # 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): def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...].""" """'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or "_" not in raw: if not isinstance(raw, str) or "_" not in raw:
@@ -219,14 +215,6 @@ def cycle_options(resources):
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {}) 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 # Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue # selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens. # #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
@@ -404,7 +392,7 @@ def cloud_current(rep):
return f"{cloudcourse.RAW_PREFIX}{slot}" return f"{cloudcourse.RAW_PREFIX}{slot}"
def cycle_write(p, rep, href=None, resources=None): def cycle_write(p, rep, href=None):
if not rep.get("x.com.samsung.da.options"): if not rep.get("x.com.samsung.da.options"):
return None return None
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX): if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
@@ -521,7 +509,7 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
return candidate if has_entity_translation("select", candidate) else "cycle" return candidate if has_entity_translation("select", candidate) else "cycle"
def options(resources): def options(resources):
rep = resources.get("/course/vs/0") or {} rep = resources.get(cloudcourse.COURSE_HREF) or {}
# Local courses first: a user-supplied cloud name that happens to # Local courses first: a user-supplied cloud name that happens to
# match a translated course name resolves back to the real local # match a translated course name resolves back to the real local
# course on write, which is the safer of the two. The options flow # course on write, which is the safer of the two. The options flow
@@ -39,14 +39,6 @@ _SENSITIVE_SUBSTRINGS = (
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) _SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
# Fields this integration merges onto a rep for its own use, which the
# device never reported (see coordinator.entity_resources). A diagnostics
# dump is meant to be exactly what the appliance said, so these are dropped
# rather than redacted -- keeping them would both misrepresent the device and
# publish data the user typed (cloud program names are user-supplied).
_SYNTHETIC_KEY_PREFIX = "x.localthings."
def _is_sensitive_key(key: str) -> bool: def _is_sensitive_key(key: str) -> bool:
lowered = key.lower() lowered = key.lower()
if lowered in _SENSITIVE_EXACT: if lowered in _SENSITIVE_EXACT:
@@ -54,29 +46,8 @@ def _is_sensitive_key(key: str) -> bool:
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
def strip_synthetic(resources):
"""Drop this integration's own merged-in fields, leaving only what the
appliance actually reported.
Separate from redact_resources because the two answer different
questions: the debug read service wants the device's unredacted state
(serial and all -- that is the point of it) but still shouldn't present
our own bookkeeping as something the device said.
"""
if isinstance(resources, dict):
return {
key: strip_synthetic(value)
for key, value in resources.items()
if not key.startswith(_SYNTHETIC_KEY_PREFIX)
}
if isinstance(resources, list):
return [strip_synthetic(item) for item in resources]
return resources
def redact_resources(resources): def redact_resources(resources):
"""Recursively redact dict values whose key matches a sensitive substring, """Recursively redact dict values whose key matches a sensitive substring.
and drop this integration's own synthetic fields entirely.
Works on the shape produced by parse_device0_batch (dict[href, rep]) or Works on the shape produced by parse_device0_batch (dict[href, rep]) or
any nested dict/list structure within a rep. any nested dict/list structure within a rep.
@@ -85,7 +56,6 @@ def redact_resources(resources):
return { return {
key: (REDACTED if _is_sensitive_key(key) else redact_resources(value)) key: (REDACTED if _is_sensitive_key(key) else redact_resources(value))
for key, value in resources.items() for key, value in resources.items()
if not key.startswith(_SYNTHETIC_KEY_PREFIX)
} }
if isinstance(resources, list): if isinstance(resources, list):
return [redact_resources(item) for item in resources] return [redact_resources(item) for item in resources]
+14 -22
View File
@@ -67,28 +67,20 @@ def _display(value, translation_key: str | None, fallback_fn=None):
""" """
if not isinstance(value, str): if not isinstance(value, str):
return value return value
# No state table for this key: either the entity isn't translated at all, known = translated_states("select", translation_key) if translation_key else frozenset()
# or its name is translated but its options deliberately aren't (an if translated := _translation_state(value, known):
# unrecognized course table, say). return translated
uncatalogued = False if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
if translation_key: return fallback
known = translated_states("select", translation_key) if translation_key and not known:
if not known: # No state table for this key: either the entity isn't translated at
uncatalogued = True # all, or its name is translated but its options deliberately aren't
elif translated := _translation_state(value, known): # (an unrecognized course table, say). Nothing named this value, so
return translated # the raw device value is the best choice -- the cosmetic reshaping
if fallback_fn is not None: # below would only mangle an opaque code, turning a course '0E' into
fallback = fallback_fn(value) # '0 E'. Reached only when the fallback *declined* the value, not
if fallback is not None: # merely when none was supplied: cycle_select always supplies one now
return fallback # (it labels cloud programs) and returns None for everything else.
if uncatalogued:
# Nothing could name this value: not the catalog, and not a
# device-specific fallback (either absent, or present and declining
# to label this one). The raw device value is the best choice --
# cosmetic reshaping below would only mangle an opaque code, turning
# a course '0E' into '0 E'. Keyed on the fallback's *result*, not on
# whether one was supplied: cycle_select always supplies one now, to
# label cloud programs, and it returns None for everything else.
return value return value
if value.islower(): if value.islower():
return value.replace("_", " ").title() return value.replace("_", " ").title()
+4 -8
View File
@@ -24,7 +24,6 @@ from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
from .coordinator import LocalThingsCoordinator, normalize_href from .coordinator import LocalThingsCoordinator, normalize_href
from .registry.redact import strip_synthetic
from .registry.subdevices import MAIN, Subdevice from .registry.subdevices import MAIN, Subdevice
ATTR_HREF = "href" ATTR_HREF = "href"
@@ -171,13 +170,10 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
# href: lets a user enumerate what exists without hammering the # href: lets a user enumerate what exists without hammering the
# device (see this module's docstring and the coordinator's # device (see this module's docstring and the coordinator's
# canonical_resources). # canonical_resources).
# Stripped, not redacted: this response is meant to be what the # device_resources, not canonical_resources: this response is what
# appliance reported (unredacted -- that is the point of a debug # the appliance reported, without the fields this integration merges
# read), but canonical_resources also carries fields this integration # on for its own use (see coordinator.entity_resources).
# merged on for its own use (see entity_resources). snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
snapshot: dict[str, Any] = {
"resources": strip_synthetic(coordinator.canonical_resources(subdevice))
}
return cast(ServiceResponse, snapshot) return cast(ServiceResponse, snapshot)
# Same normalize-before-translate order as the write path above. # Same normalize-before-translate order as the write path above.
+8 -8
View File
@@ -90,8 +90,8 @@ class TestRealDumps:
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
assert store.observe(rep) is True assert store.observe(rep) is True
assert store.blob("55") == SPORTS assert store.snapshot()["slots"]["55"]["blob"] == SPORTS
assert store.blob("6B") == JEANS assert store.snapshot()["slots"]["6B"]["blob"] == JEANS
# Learned but unnamed -- nothing is offerable yet. # Learned but unnamed -- nothing is offerable yet.
assert store.named() == {} assert store.named() == {}
assert store.view() == {} assert store.view() == {}
@@ -102,7 +102,7 @@ class TestRealDumps:
store.observe(rep) store.observe(rep)
assert store.download_candidates() == ["87"] assert store.download_candidates() == ["87"]
# A candidate is never used until confirmed. # A candidate is never used until confirmed.
assert store.download_course() is None assert store.snapshot()["download_course"] is None
def test_wa55_learns_its_saved_program_but_not_the_sentinel(self): def test_wa55_learns_its_saved_program_but_not_the_sentinel(self):
rep = _load_device("washer_wa55a7700av")["/course/vs/0"] rep = _load_device("washer_wa55a7700av")["/course/vs/0"]
@@ -110,8 +110,8 @@ class TestRealDumps:
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
store.observe(rep) store.observe(rep)
assert store.blob("59") == "001C590549164D114A224C2037F0AC22" assert store.snapshot()["slots"]["59"]["blob"] == "001C590549164D114A224C2037F0AC22"
assert store.blob("01") is None # the FFFF sentinel's byte 2 assert "01" not in store.snapshot()["slots"] # the FFFF sentinel's byte 2
def test_wa55_proposes_no_download_course(self): def test_wa55_proposes_no_download_course(self):
"""It is sitting on an ordinary local course with no override """It is sitting on an ordinary local course with no override
@@ -160,7 +160,7 @@ class TestRealDumps:
for blob in (b06c, b048): for blob in (b06c, b048):
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"])) store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"]))
assert store.blob("0A") == blob assert store.snapshot()["slots"]["0A"]["blob"] == blob
def test_a_sentinel_slot_byte_is_not_the_current_course(self): def test_a_sentinel_slot_byte_is_not_the_current_course(self):
"""The two sentinels in the corpus disagree about this -- WA55's byte """The two sentinels in the corpus disagree about this -- WA55's byte
@@ -192,14 +192,14 @@ class TestStoreRules:
offers.""" offers."""
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"])) store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"]))
assert store.blob("6B") is None assert "6B" not in store.snapshot()["slots"]
def test_a_relearned_blob_replaces_the_old_payload(self): def test_a_relearned_blob_replaces_the_old_payload(self):
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])) store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
rewritten = SPORTS.replace("F005F0AC00", "F005F0AC11") rewritten = SPORTS.replace("F005F0AC00", "F005F0AC11")
assert store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{rewritten}"])) is True assert store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{rewritten}"])) is True
assert store.blob("55") == rewritten assert store.snapshot()["slots"]["55"]["blob"] == rewritten
def test_observing_the_same_rep_twice_changes_nothing(self): def test_observing_the_same_rep_twice_changes_nothing(self):
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
+23 -37
View File
@@ -80,8 +80,8 @@ async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistan
entry = _entry(hass) entry = _entry(hass)
coordinator = await _coordinator(hass, entry) coordinator = await _coordinator(hass, entry)
assert coordinator.cloud_courses.blob("55") == SPORTS assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
assert coordinator.cloud_courses.blob("6B") == JEANS assert coordinator.cloud_courses.snapshot()["slots"]["6B"]["blob"] == JEANS
# Survives a restart -- the payload is only visible while loaded. # Survives a restart -- the payload is only visible while loaded.
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
@@ -96,7 +96,7 @@ async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
source="optimistic", source="optimistic",
) )
await _flush(hass) await _flush(hass)
assert coordinator.cloud_courses.blob("55") is None assert "55" not in coordinator.cloud_courses.snapshot()["slots"]
async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant): async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant):
@@ -109,8 +109,7 @@ async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: H
async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant): async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant):
coordinator = await _coordinator(hass) coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87") coordinator.apply_cloud_courses({"55": "Sports"}, "87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass) await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator) assert "cloud:55" in _cycle_options(coordinator)
@@ -118,7 +117,7 @@ async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant)
# Jeans is still unnamed -- so the state falls through to the raw course. # Jeans is still unnamed -- so the state falls through to the raw course.
assert _cycle_state(coordinator) == "87" assert _cycle_state(coordinator) == "87"
coordinator.set_cloud_course_name("6B", "Jeans") coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
await _flush(hass) await _flush(hass)
assert _cycle_state(coordinator) == "cloud:6B" assert _cycle_state(coordinator) == "cloud:6B"
@@ -128,8 +127,7 @@ async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeA
appliance reported, so it can't be polled over, written back, or land in appliance reported, so it can't be polled over, written back, or land in
a diagnostics dump.""" a diagnostics dump."""
coordinator = await _coordinator(hass) coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87") coordinator.apply_cloud_courses({"55": "Sports"}, "87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass) await _flush(hass)
assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE] assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE]
@@ -142,8 +140,7 @@ async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant)
command path builds its own rep, and a rep taken straight off the state 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.""" cache carries no cloud programs, so the write would silently no-op."""
coordinator = await _coordinator(hass) coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87") coordinator.apply_cloud_courses({"55": "Sports"}, "87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass) await _flush(hass)
sent: list[tuple[list[str], bytes]] = [] sent: list[tuple[list[str], bytes]] = []
@@ -184,7 +181,6 @@ async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssista
assert issue is not None assert issue is not None
assert (issue.translation_placeholders or {})["total"] == "9" assert (issue.translation_placeholders or {})["total"] == "9"
coordinator.set_cloud_download_course("87")
for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()): for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()):
# Only two are learned; name every advertised slot to close the gap. # Only two are learned; name every advertised slot to close the gap.
coordinator.cloud_courses.observe( coordinator.cloud_courses.observe(
@@ -195,7 +191,7 @@ async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssista
] ]
} }
) )
coordinator.set_cloud_course_name(slot, f"Program {slot}") coordinator.apply_cloud_courses({slot: f"Program {slot}"}, "87")
await _flush(hass) await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is None assert registry.async_get_issue(DOMAIN, issue_id) is None
@@ -219,7 +215,7 @@ async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant
entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}}) entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}})
coordinator = await _coordinator(hass, entry) coordinator = await _coordinator(hass, entry)
# Dropped on restore, then relearned from the live poll. # Dropped on restore, then relearned from the live poll.
assert coordinator.cloud_courses.blob("55") == SPORTS assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -276,19 +272,6 @@ async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant)
assert coordinator.cloud_courses.named() == {} assert coordinator.cloud_courses.named() == {}
async def test_the_flow_rejects_a_name_that_shadows_a_local_course(hass: HomeAssistant):
"""'Drum Clean' is course 74 on this appliance's own list. Two options
rendering the same label would resolve to whichever comes first."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_courses(
{"name_55": "Drum Clean", "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): async def test_clearing_a_name_removes_the_program_from_the_select(hass: HomeAssistant):
coordinator = await _coordinator(hass) coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator) handler = await _options_handler(hass, coordinator)
@@ -358,27 +341,30 @@ async def test_the_synthetic_field_never_reaches_diagnostics(
entry = _entry(hass) entry = _entry(hass)
coordinator = await _coordinator(hass, entry) coordinator = await _coordinator(hass, entry)
coordinator.set_cloud_download_course("87") coordinator.apply_cloud_courses({"55": "Marc's weekend towels"}, "87")
coordinator.set_cloud_course_name("55", "Marc's weekend towels")
await _flush(hass) await _flush(hass)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
dump = json.dumps(await async_get_config_entry_diagnostics(hass, entry)) diag = await async_get_config_entry_diagnostics(hass, entry)
dump = json.dumps(diag)
assert cloudcourse.FIELD not in dump assert cloudcourse.FIELD not in dump
assert "Marc's weekend towels" not in dump # The device's own tokens are still there -- resources stays what it said.
# The device's own tokens are still there -- only our field is dropped.
assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump
# The store is reported in its own block, so a triager can see the
# payloads -- but not the user's chosen names.
assert "Marc's weekend towels" not in dump
assert diag["cloud_courses"]["payloads"]["55"] == SPORTS
assert diag["cloud_courses"]["named_slots"] == ["55"]
assert diag["cloud_courses"]["download_course"] == "87"
async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant): async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant):
from custom_components.localthings.registry.redact import strip_synthetic
coordinator = await _coordinator(hass) coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87") coordinator.apply_cloud_courses({"55": "Sports"}, "87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass) await _flush(hass)
stripped = strip_synthetic(coordinator.canonical_resources(MAIN)) stripped = coordinator.device_resources(MAIN)
assert cloudcourse.FIELD not in stripped[COURSE] assert cloudcourse.FIELD not in stripped[COURSE]
assert "x.com.samsung.da.options" in stripped[COURSE] assert "x.com.samsung.da.options" in stripped[COURSE]
@@ -392,7 +378,7 @@ async def test_the_flow_rejects_a_download_course_the_device_does_not_offer(
result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"}) result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"})
assert result["errors"] == {"base": "cloud_course_unknown_course"} assert result["errors"] == {"base": "cloud_course_unknown_course"}
assert coordinator.cloud_courses.download_course() is None assert coordinator.cloud_courses.snapshot()["download_course"] is None
assert coordinator.cloud_courses.named() == {} assert coordinator.cloud_courses.named() == {}