Adds washer_cycle_table_00 and dryer_cycle_table_00 translation catalog entries, confirmed by the issue #357 reporter selecting each cycle on a WF45R6300AW/US washer and DVE45R6300W/A3 dryer and reading back the raw course code. Table_00 is a separate, older course-code family from the existing Table_02/Table_03 catalogs -- laundry.cycle_select's table_href scoping already keeps them apart, so this is a translations-only change. Table_00 was previously used only as an example of an unconfirmed table in tests; those now use Table_99 for that role, and new tests assert the confirmed codes translate and that the resolved key routes to the new table-scoped catalog entries. Mirrored to all shipped languages (cs/de/es/it/ko/nl) to keep tests/test_translations.py's key-for-key invariant.
413 lines
16 KiB
Python
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_99"}}
|
|
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"
|