Files
localthings/tests/test_laundry_capabilities.py
T
Marc Billow 711a71876d Merge pull request #350 from galaxysj/codex/fix-washer-course-enum-display
Fix AC setup timeout and verify appliance course mappings
2026-08-10 12:19:21 -04:00

413 lines
16 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_decodes_reported_washer_course_table_including_personal_slots(self):
"""Real DA_WM_TP1_21_COMMON diagnostics, with every selectable
standard and personal-course code preserved in the device's order.
F1/F3 are part of the packed list; their labels come from the
companion personal-course resource tested below.
"""
resources = {
"/wm/editcourse/vs/0": {
"x.com.samsung.da.editCourseList": (
"EditCourseList_696F73757801719688706D6A76726C6E6B777479F1F3"
),
},
}
assert laundry.cycle_options(resources) == [
"69",
"6F",
"73",
"75",
"78",
"01",
"71",
"96",
"88",
"70",
"6D",
"6A",
"76",
"72",
"6C",
"6E",
"6B",
"77",
"74",
"79",
"F1",
"F3",
]
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",
]
def test_decodes_reported_dishwasher_course_table_without_editcourse(self):
"""Real DA_DW_A51_20_COMMON diagnostics: the board has no
/wm/editcourse/vs/0, so every selectable code must come from its
packed supportedOptions table, including the newer 82/8A/A7/A8/8C
family that previously appeared as raw UI text.
"""
resources = {
"/course/vs/0": {
"x.com.samsung.da.options": ["Course_82"],
"x.com.samsung.da.supportedOptions": [
"482E0026002F00270028AE0026002F0027002A7E0026002F00270028"
"0E0006000F0027000A8E0026002F002700288E0006000F00070008C"
"E0026002F00270028DE0026002F00270028EE0006000F00270028FE"
"0006002F0027002"
],
},
}
assert laundry.cycle_options(resources) == [
"82",
"8A",
"A7",
"80",
"A8",
"88",
"8C",
"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"