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.
63 lines
2.7 KiB
Python
63 lines
2.7 KiB
Python
"""Tests for dishwasher-specific capabilities.
|
|
|
|
The shared /course/vs/0 cycle-select machinery (parse_edit_course_list,
|
|
cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
|
|
check the dishwasher wiring and its device-specific options.
|
|
"""
|
|
|
|
from custom_components.localthings.registry.capabilities import dishwasher
|
|
from custom_components.localthings.registry.entities import SwitchDesc
|
|
|
|
|
|
class TestCycleOptions:
|
|
def _cycle(self):
|
|
return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "cycle")
|
|
|
|
def test_cycle_desc_uses_shared_cycle_options(self):
|
|
desc = self._cycle()
|
|
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E82"}}
|
|
assert desc.options(live) == ["0E", "82"]
|
|
assert desc.translation_key == "dishwasher_cycle"
|
|
|
|
def test_exists_only_when_edit_course_list_is_live(self):
|
|
desc = self._cycle()
|
|
assert desc.exists_fn({}, {}) is False
|
|
assert desc.exists_fn({}, {"/wm/editcourse/vs/0": {}}) is False
|
|
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E"}}
|
|
assert desc.exists_fn({}, live) is True
|
|
|
|
def test_cycle_write_uses_raw_code_directly(self):
|
|
desc = self._cycle()
|
|
rep = {"x.com.samsung.da.options": ["DeviceType_0001", "Course_0E", "GMT_04"]}
|
|
path, body = desc.write_fn("90", rep)
|
|
assert path == ["course", "vs", "0"]
|
|
assert body == {"x.com.samsung.da.options": ["Course_90"]}
|
|
|
|
|
|
class TestDishwasherOptions:
|
|
def test_storm_wash_read_and_write(self):
|
|
desc = next(
|
|
e
|
|
for e in dishwasher.CYCLE_OPTIONS.entities
|
|
if e.key == "storm_wash" and isinstance(e, SwitchDesc)
|
|
)
|
|
assert desc.rep_fn is not None
|
|
assert desc.rep_fn({"x.com.samsung.da.options": ["StormWashZone_On"]}) is True
|
|
assert desc.rep_fn({"x.com.samsung.da.options": ["StormWashZone_Off"]}) is False
|
|
assert desc.write_fn is not None
|
|
result = desc.write_fn("Off", {"x.com.samsung.da.options": ["StormWashZone_On"]})
|
|
assert result is not None
|
|
path, body = result
|
|
assert path == ["course", "vs", "0"]
|
|
assert "StormWashZone_Off" in body["x.com.samsung.da.options"]
|
|
|
|
def test_auto_release_exists_only_when_field_present(self):
|
|
desc = next(
|
|
e
|
|
for e in dishwasher.CYCLE_OPTIONS.entities
|
|
if e.key == "auto_release_dry" and isinstance(e, SwitchDesc)
|
|
)
|
|
assert desc.exists_fn is not None
|
|
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
|
assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True
|