Files
localthings/tests/test_laundry_capabilities.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

343 lines
14 KiB
Python

"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.entities import SelectDesc
class TestCourseHelpers:
def test_parses_edit_course_list(self):
raw = "EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A"
assert laundry.parse_edit_course_list(raw) == [
"1C",
"1D",
"21",
"1B",
"1E",
"29",
"24",
"33",
"28",
"26",
"27",
"22",
"20",
"23",
"25",
"32",
"2F",
"2E",
"30",
"66",
"2D",
"8F",
"96",
"2B",
"2A",
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
assert laundry.parse_edit_course_list(None) == []
assert laundry.parse_edit_course_list("") == []
assert laundry.parse_edit_course_list("no underscore") == []
def test_cycle_options_reads_live_edit_course_list(self):
"""The device's own course list -- including a code ('65') never seen
on the primary dump -- is used as-is; there is no hardcoded table."""
resources = {
"/wm/editcourse/vs/0": {
"x.com.samsung.da.editCourseList": "EditCourseList_651C",
},
}
assert laundry.cycle_options(resources) == ["65", "1C"]
def test_cycle_options_empty_when_resource_absent_or_empty(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({"/wm/editcourse/vs/0": {}}) == []
def test_option_value(self):
opts = ["DeviceType_0167", "Course_1C", "GMT_04"]
assert laundry.option_value(opts, "Course") == "1C"
assert laundry.option_value(opts, "Missing") is None
def test_decodes_device_provided_personal_course_names(self):
"""Real /wm/personalcourse/vs/0 entries from the reported washer.
The first TLV field contains a byte-counted UTF-8 name. It is the only
course-name metadata in the diagnostics and is safe to display without
assigning an inferred meaning to any standard course code.
"""
resources = {
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": [
"F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1",
"F2_00",
"F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9",
],
},
}
first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8")
second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8")
assert laundry.personal_course_labels(resources) == {
"F1": first_name,
"F3": second_name,
}
def test_personal_course_names_reject_malformed_payloads(self):
resources = {
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": [
"F1_not-hex",
"F2_0106AA",
"F3_020141",
None,
],
},
}
assert laundry.personal_course_labels(resources) == {}
def test_washer_cycle_fallback_only_labels_personal_courses(self):
"""No invented English label for an unrecognized code (PR #251 review)."""
resources = {
"/wm/personalcourse/vs/0": {
"x.com.samsung.da.courses": ["F1_0106EC868DEC98B7"],
},
}
expected = bytes.fromhex("EC868DEC98B7").decode("utf-8")
assert laundry.washer_cycle_fallback("F1", resources) == expected
assert laundry.washer_cycle_fallback("69", resources) is None
assert laundry.washer_cycle_fallback("6f", resources) is None
assert laundry.washer_cycle_fallback("Normal", resources) is None
class TestCourseCodesFromSupportedOptions:
"""cycle_options()'s fallback for boards that populate
/wm/editcourse/vs/0 without ever filling in editCourseList itself
(issue #1) -- derives the course list from /course/vs/0's own
supportedOptions instead."""
# Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble
# header followed by 14 self-indexed 7-byte-per-course records. '1C' (the
# first record) is confirmed as "Eco 40-60" both by this device's own
# currently-selected course matching the SmartThings app screenshot's
# checked item, and by six other independent devices' already-shipped
# translations agreeing on the same code -> name mapping.
_REAL_SUPPORTED_OPTIONS = (
"31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F"
"088000913FA67F7485209204A5208780009000A00006841E930FA30F"
"7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F"
"34841E923FA67FA0811E933FA33F"
)
def test_derives_codes_when_edit_course_list_is_empty(self):
resources = {
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": ""},
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_1C"],
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == [
"1C",
"1B",
"25",
"20",
"08",
"74",
"87",
"06",
"7F",
"65",
"8F",
"96",
"34",
"A0",
]
def test_edit_course_list_still_takes_priority(self):
"""A live editCourseList wins even with supportedOptions present --
no reason to prefer a derived list over the authoritative one."""
resources = {
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_651C"},
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_1C"],
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == ["65", "1C"]
def test_rejects_a_table_missing_the_current_course(self):
"""The device's own currently-selected course must be a member of
its derived list -- a mismatch means the guess is wrong, not that
the device selected something outside its own supported set."""
resources = {
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_FF"],
"x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == []
def test_smallest_passing_split_wins_over_its_own_multiples(self):
"""K=2 and K=4 both trivially re-pass the same two checks here --
each is just a sparser sampling of the true, smaller K=1 table (its
first bytes are a subset of K=1's, so uniqueness and "contains the
current course" carry over for free) -- but K=1 is the real, most
specific table and must be the one returned."""
resources = {
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_AA"],
"x.com.samsung.da.supportedOptions": ["0AABBCCDD"],
},
}
assert laundry.cycle_options(resources) == ["AA", "BB", "CC", "DD"]
def test_empty_without_supported_options_or_course_href(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({"/course/vs/0": {}}) == []
def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self):
"""Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also
pass both checks here, and none of them are multiples of 7 --
position 0 lands on the same real course code ('0e') regardless of
K, which alone satisfies the current-course guard for several
unrelated splits. Smallest-K-wins is a heuristic that matches every
real dump checked so far, not a proven guarantee -- see
_course_codes_from_supported_options's docstring."""
resources = {
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_0E"],
"x.com.samsung.da.supportedOptions": [
"30E5434B102D102835034B002D002845034B002D002805034B000D000"
"865034B002D002075000B000D000905000B000D0008D5034B002D0028"
"E5034B000D0008F5034B000D000"
],
},
}
assert laundry.cycle_options(resources) == [
"0E",
"83",
"84",
"80",
"86",
"07",
"90",
"8D",
"8E",
"8F",
]
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="mdi:tumble-dryer")
assert desc.key == "cycle"
assert desc.translation_key == "dryer_cycle"
assert desc.icon == "mdi:tumble-dryer"
# Behavior, not identity: the option list is the device's own live
# course list (plus any named cloud programs -- see cycle_select).
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_161C"}}
assert desc.options(live) == ["16", "1C"]
def test_reads_raw_course_code_from_options(self):
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"]}
assert desc.rep_fn(rep) == "16"
assert desc.rep_fn({"x.com.samsung.da.options": ["GMT_04"]}) is None
def test_exists_only_when_edit_course_list_is_live(self):
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
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_16"}}
assert desc.exists_fn({}, live) is True
def test_cycle_write_is_single_token(self):
"""Confirmed on real hardware (issue #54): the device merges a
single-token options[] write by prefix itself, so the write only
needs to carry the changed token, not the whole rewritten array."""
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"]}
path, body = desc.write_fn("1D", rep)
assert path == ["course", "vs", "0"]
assert body == {"x.com.samsung.da.options": ["Course_1D"]}
def test_cycle_write_noop_without_options(self):
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
assert desc.write_fn("1D", {}) is None
class TestCycleSelectTableGating:
"""translation_key becomes a resolver, not a plain string, once
table_href is given -- washer/dryer's real call sites (issue: course
codes aren't guaranteed consistent across board generations sharing
the same /course/vs/0 contract; FlexWash's older board reports a
different course table than every device the shipped translations
were confirmed against). The resolved key is built from a reported
table id. Devices without one use the generic translated Cycle entity
name while preserving raw option codes as a safe fallback."""
def _desc(self):
return laundry.cycle_select(
translation_key="washer_cycle",
icon="x",
table_href="/st/washercourse/vs/0",
)
def test_static_string_when_no_table_href_given(self):
"""dishwasher's call site -- no equivalent table-id resource in any
dump seen, no evidence of the same cross-board risk -- keeps the
plain static key unconditionally."""
desc = laundry.cycle_select(translation_key="dishwasher_cycle", icon="x")
assert desc.translation_key == "dishwasher_cycle"
def test_resolved_key_is_built_from_the_reported_table(self):
desc = self._desc()
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}}
assert callable(desc.translation_key)
assert desc.translation_key(resources) == "washer_cycle_table_02"
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(resources) == "cycle"
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
"""An absent table id still gets a translated generic entity name."""
desc = self._desc()
assert desc.translation_key({}) == "cycle"
assert desc.translation_key({"/st/washercourse/vs/0": {}}) == "cycle"
class TestBuzzerSound:
def test_href(self):
assert laundry.BUZZER_SOUND.href == "/buzzersound/vs/0"
def test_buzzer_sound_write(self):
desc = next(
e
for e in laundry.BUZZER_SOUND.entities
if e.key == "buzzer_sound" and isinstance(e, SelectDesc)
)
assert desc.options_field == "supportedBuzzerSound"
assert desc.write_fn is not None
result = desc.write_fn("On", {})
assert result is not None
path, body = result
assert path == ["buzzersound", "vs", "0"]
assert body == {"setBuzzerSound": "On"}
def test_finish_sound_exists_only_when_supported(self):
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == "finish_sound")
assert desc.exists_fn is not None
assert desc.exists_fn({"setBuzzerSound": "On"}, {}) is False
assert desc.exists_fn({"supportedFinishSound": ["FinishSound_1"]}, {}) is True
class TestJobBeginningStatus:
def test_href_and_field(self):
assert laundry.JOB_BEGINNING_STATUS.href == "/wm/jobbeginingstatus/vs/0"
desc = laundry.JOB_BEGINNING_STATUS.entities[0]
assert desc.field == "x.com.samsung.da.currentStatus"
assert desc.entity_category == "diagnostic"