Files
localthings/tests/test_cloud_courses_flow.py
T
Marc Billow f45bd6a72c 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.
2026-08-09 21:21:10 +00:00

323 lines
12 KiB
Python

"""End-to-end wiring for cloud "Download" cycles (issue #342).
The store's own rules are covered in test_cloud_courses.py. This file covers
the parts that only exist once a coordinator is running: learning from an
applied rep, persisting to the config entry, surfacing the store to entity
descriptors through the synthetic field, the Repairs nudge, and the options
flow that supplies the names.
"""
from __future__ import annotations
import asyncio
from typing import Any, cast
import cbor2
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import cloudcourse
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import SelectDesc
from tests.conftest import _load_device
from tests.test_subdevice_discovery import ENTRY_DATA
FIXTURE = "washer_ww5000c_cloud"
COURSE = cloudcourse.COURSE_HREF
SPORTS = "0021550449284D134AA04C0035F004F005F0AC00"
JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00"
async def _flush(hass: HomeAssistant) -> None:
"""hass.add_job hops used by the persist path."""
for _ in range(3):
await asyncio.sleep(0)
await hass.async_block_till_done()
def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, **(data or {})},
unique_id="localthings_CLOUD-COURSE-TEST",
)
entry.add_to_hass(hass)
return entry
async def _coordinator(hass: HomeAssistant, entry=None) -> LocalThingsCoordinator:
coordinator = LocalThingsCoordinator(hass, entry or _entry(hass))
resources = _load_device(FIXTURE)
coordinator._run_discovery(resources)
for href, rep in resources.items():
coordinator._observe.apply(href, rep, source="poll")
await _flush(hass)
return coordinator
def _cycle_state(coordinator: LocalThingsCoordinator):
from custom_components.localthings.registry.adapter import flatten
return flatten(coordinator.bound, coordinator.entity_resources()).get("cycle")
def _cycle_desc(coordinator: LocalThingsCoordinator) -> SelectDesc:
return cast(SelectDesc, next(b.desc for b in coordinator.bound if b.desc.key == "cycle"))
def _cycle_options(coordinator: LocalThingsCoordinator):
from custom_components.localthings.registry.subdevices import MAIN
return _cycle_desc(coordinator).options(coordinator.canonical_resources(MAIN))
async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
assert coordinator.cloud_courses.blob("55") == SPORTS
assert coordinator.cloud_courses.blob("6B") == JEANS
# Survives a restart -- the payload is only visible while loaded.
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
"""An optimistic rep is what this integration just wrote, not what the
appliance reported."""
coordinator = LocalThingsCoordinator(hass, _entry(hass))
coordinator._observe.apply(
COURSE,
{"x.com.samsung.da.options": ["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]},
source="optimistic",
)
await _flush(hass)
assert coordinator.cloud_courses.blob("55") is None
async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant):
coordinator = await _coordinator(hass)
options = _cycle_options(coordinator)
assert all(not o.startswith(cloudcourse.RAW_PREFIX) for o in options)
# The local courses are untouched.
assert "87" in options
async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant):
coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
# The fixture is sitting on Download with a Jeans one-time override, and
# Jeans is still unnamed -- so the state falls through to the raw course.
assert _cycle_state(coordinator) == "87"
coordinator.set_cloud_course_name("6B", "Jeans")
await _flush(hass)
assert _cycle_state(coordinator) == "cloud:6B"
async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeAssistant):
"""It is merged at read time only: last_resources stays exactly what the
appliance reported, so it can't be polled over, written back, or land in
a diagnostics dump."""
coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass)
assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE]
assert cloudcourse.FIELD not in coordinator.last_resources[COURSE]
assert cloudcourse.FIELD not in coordinator.resource(COURSE)
async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant):
"""Driven through async_send_command rather than write_fn directly: the
command path builds its own rep, and a rep taken straight off the state
cache carries no cloud programs, so the write would silently no-op."""
coordinator = await _coordinator(hass)
coordinator.set_cloud_download_course("87")
coordinator.set_cloud_course_name("55", "Sports")
await _flush(hass)
sent: list[tuple[list[str], bytes]] = []
class _FakeSession:
def post(self, path_segs, payload, timeout=None):
sent.append((path_segs, payload))
return 0x44, b""
coordinator._session = cast(Any, _FakeSession())
bound = next(b for b in coordinator.bound if b.desc.key == "cycle")
await coordinator.async_send_command(bound, "cloud:55")
assert len(sent) == 1
path_segs, payload = sent[0]
assert path_segs == ["course", "vs", "0"]
assert cbor2.loads(payload)["x.com.samsung.da.options"] == [
"Course_87",
f"OneTimeCloudCourse_{SPORTS}",
]
# The optimistic merge keeps the rest of the array intact for the settle
# window -- including the device's own slot list and the saved default.
merged = coordinator.resource(COURSE)["x.com.samsung.da.options"]
assert "Course_87" in merged
assert f"OneTimeCloudCourse_{SPORTS}" in merged
assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged
async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
issue_id = f"cloud_courses_{entry.entry_id}"
issue = registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert (issue.translation_placeholders or {})["total"] == "9"
coordinator.set_cloud_download_course("87")
for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()):
# Only two are learned; name every advertised slot to close the gap.
coordinator.cloud_courses.observe(
{
"x.com.samsung.da.options": [
"CloudExtraCourse_0A5C286B2D0C55301A",
f"CloudCourse_0000{slot}0449284D134AA04C0035F004F005F0AC00",
]
}
)
coordinator.set_cloud_course_name(slot, f"Program {slot}")
await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is None
async def test_no_repair_for_a_device_without_downloaded_programs(hass: HomeAssistant):
entry = _entry(hass)
coordinator = LocalThingsCoordinator(hass, entry)
resources = _load_device("washer")
coordinator._run_discovery(resources)
for href, rep in resources.items():
coordinator._observe.apply(href, rep, source="poll")
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant):
entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}})
coordinator = await _coordinator(hass, entry)
# Dropped on restore, then relearned from the live poll.
assert coordinator.cloud_courses.blob("55") == SPORTS
# ---------------------------------------------------------------------------
# Options flow
# ---------------------------------------------------------------------------
async def _options_handler(hass: HomeAssistant, coordinator: LocalThingsCoordinator):
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
entry = coordinator.config_entry
assert entry is not None
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
handler = LocalThingsOptionsFlow()
handler.hass = hass
# OptionsFlow.config_entry resolves through `handler`, which the flow
# manager normally sets when it starts the flow.
handler.handler = entry.entry_id
return handler
async def test_the_menu_offers_download_cycles_only_when_the_device_has_them(
hass: HomeAssistant,
):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_init()
assert "cloud_courses" in result["menu_options"]
async def test_naming_through_the_flow_persists(hass: HomeAssistant):
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_courses()
await handler.async_step_cloud_courses(
{"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"}
)
await _flush(hass)
assert coordinator.cloud_courses.named() == {"55": "Sports", "6B": "Jeans"}
assert entry.data[CONF_CLOUD_COURSES]["download_course"] == "87"
async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_courses(
{"name_55": "Sports", "name_6B": "sports", "download_course": "87"}
)
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
assert coordinator.cloud_courses.named() == {}
async def test_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):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "87"})
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
await handler.async_step_cloud_courses({"name_55": "", "download_course": "87"})
await _flush(hass)
assert "cloud:55" not in _cycle_options(coordinator)
async def test_without_a_confirmed_download_course_nothing_is_offered(hass: HomeAssistant):
"""Names alone aren't enough -- there'd be no course code to write."""
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": ""})
await _flush(hass)
assert coordinator.cloud_courses.named() == {"55": "Sports"}
assert "cloud:55" not in _cycle_options(coordinator)
async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistant):
coordinator = await _coordinator(hass)
handler = await _options_handler(hass, coordinator)
result = await handler.async_step_cloud_courses()
assert result["step_id"] == "cloud_courses"
assert result["description_placeholders"]["total"] == "9"
# Two learned so far, seven still to walk through on the appliance.
assert result["description_placeholders"]["found"] == "2"
assert coordinator.cloud_courses.download_candidates() == ["87"]