diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 9b14503..ab3a642 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -416,8 +416,12 @@ def enumerate_subdevices( # /subdevices/vs/0 and /device/ 404s; the only trace of the sibling # is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice # link). Its own tree answers a full Collection at //device/0, - # exactly Pattern B's transform, so every UUID path prefix seen in - # /oic/res is treated as a candidate. _probe_prefixed's probed_ids + # exactly Pattern B's transform, so a UUID prefix attached to that + # resource type is treated as a candidate. Other UUID-prefixed links are + # not evidence of a sibling: some single-unit AC boards advertise only + # per-prefix file-transfer resources, and probing those prefixes against + # every master href needlessly burns the setup timeout budget. + # _probe_prefixed's probed_ids # guard (not a set difference against `listed`) is what keeps an id # already named by subdeviceIdList from being probed twice, since the # two sources can disagree on case. @@ -426,7 +430,7 @@ def enumerate_subdevices( m.group(1) for link in _iter_oic_res_hrefs(oic_res_links) for m in [_UUID_PREFIX_RE.match(link.get("href", ""))] - if m + if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ()) } ) for sub_id in linked: diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index 56612c9..05322fe 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -54,6 +54,45 @@ class TestCourseHelpers: 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" @@ -226,6 +265,37 @@ class TestCourseCodesFromSupportedOptions: "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): diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index 3b4dd96..4ffe4f3 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -625,7 +625,10 @@ def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_no { "di": "a", "links": [ - {"href": f"/{_UUID}/multidevice/vs/0"}, + { + "href": f"/{_UUID}/multidevice/vs/0", + "rt": ["x.com.samsung.da.multidevice"], + }, ], } ] @@ -650,7 +653,10 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() { "di": "a", "links": [ - {"href": f"/{_UUID}/multidevice/vs/0"}, + { + "href": f"/{_UUID}/multidevice/vs/0", + "rt": ["x.com.samsung.da.multidevice"], + }, ], } ] @@ -666,6 +672,37 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() assert subdevices[0].key.lower() == _UUID +def test_uuid_prefixed_file_links_do_not_trigger_flat_subdevice_probe(): + """A single-unit AC can advertise UUID-prefixed file-transfer links. + + They identify a transfer namespace, not a sibling indoor unit. Treating + the prefix as Pattern C makes setup probe every master href under a UUID + that never answers, consuming the whole enumeration timeout budget. + """ + oic_res = [ + { + "di": "a", + "links": [ + { + "href": f"/{_UUID}/file/transfer/vs/0", + "rt": ["x.com.samsung.file.transfer"], + }, + { + "href": f"/{_UUID}/file/list/vs/0", + "rt": ["x.com.samsung.file.list"], + }, + ], + } + ] + sess = _FakeSession({}) + probes: dict[str, bool] = {} + + subdevices, _extra = enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__) + + assert subdevices == [] + assert not any(href.startswith(f"/{_UUID}/") for href in probes) + + # --------------------------------------------------------------------------- # discover_partitioned # --------------------------------------------------------------------------- diff --git a/tests/test_translations.py b/tests/test_translations.py index 9309a01..2c9b7cd 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -190,6 +190,38 @@ def test_confirmed_washer_table_02_missing_course_names(): } +def test_reported_washer_standard_courses_all_have_table_02_labels(): + """Every non-personal code in the reported washer's live course list + must resolve through the Table_02 catalog instead of appearing as raw + text. F1/F3 deliberately come from device-provided personal names. + """ + states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] + reported = { + "69", + "6f", + "73", + "75", + "78", + "01", + "71", + "96", + "88", + "70", + "6d", + "6a", + "76", + "72", + "6c", + "6e", + "6b", + "77", + "74", + "79", + } + + assert reported <= states.keys() + + def test_confirmed_dishwasher_course_names(): states = _load("en")["entity"]["select"]["dishwasher_cycle"]["state"] assert {