From 6bcf8f3becd97102d52029267c4fc1a1c47c22da Mon Sep 17 00:00:00 2001 From: Hyunook Date: Sat, 1 Aug 2026 08:18:31 +0900 Subject: [PATCH 1/2] feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241) AWM-WW-AID-26-ONEBODY (washer+dryer combo) reports numofsubdevice='2' on /multidevice/vs/0 but carries no /subdevices/vs/0 (no subdeviceIdList -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). The washer subdevice's UUID appears only as the path prefix of the x.com.samsung.da.multidevice link in /oic/res; GET //device/0 answers the washer's own full Collection batch (model ..._WF80H vs the master's ..._DV80H27H). Treat every UUID path prefix seen in /oic/res as a prefixed-subdevice candidate (minus ones subdeviceIdList already named), probed with the same tolerated-404 seed RETRIEVE as Pattern B -- the shared body is factored into _probe_prefixed. discover_partitioned's entity-level liveness gate still decides materialization, so a UUID link with no live sibling behind it contributes nothing. Fixture is a live capture from the reporting board (serials/MACs/di scrubbed); tests cover discovery, probe hygiene, washer-side entity binding, and that the master's own entity set is unchanged. --- .../localthings/registry/subdevices.py | 77 +- .../washer_dryer_onebody_awm_device.json | 1364 +++++++++++++++++ tests/test_onebody_washer_subdevice.py | 92 ++ 3 files changed, 1519 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/washer_dryer_onebody_awm_device.json create mode 100644 tests/test_onebody_washer_subdevice.py diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index aed9bb4..4e11e78 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -36,7 +36,18 @@ the only thing ever actually confirmed to work for this pattern -- on the assumption that a composite device's siblings share the master's resource surface. See `Subdevice.flat_hrefs`. -Both are "the same thing wearing different clothes": a logical subdevice is a +Pattern C -- UUID prefix advertised only via `/oic/res` +(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers +`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0` +(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer +subdevice's UUID appears nowhere except as the path prefix of the +`x.com.samsung.da.multidevice` link in `/oic/res`, and +`GET //device/0` answers the washer's own full Collection batch +(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's +transform with the UUID sourced from the link prefix instead of +`subdeviceIdList`. + +All of these are "the same thing wearing different clothes": a logical subdevice is a seed collection path to poll, plus an href transform between the canonical href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The detection signals don't overlap on either captured board (the Pattern A @@ -88,6 +99,12 @@ from .batch import parse_device0_batch _INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$') +# A UUID as the first path segment of an /oic/res link href -- Pattern C's +# discovery signal (issue #241): a subdevice tree whose UUID is advertised +# nowhere except as this prefix (no subdeviceIdList, no /device/). +_UUID_PREFIX_RE = re.compile( + r'^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/') + # Speculative /device/ siblings probed when /oic/res doesn't reveal a # second logical subdevice's Collection on this board (moved here from # identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the @@ -338,17 +355,11 @@ def enumerate_subdevices( if probe_log is not None: probe_log(seed_href, bool(batch)) - # --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------ - raw_ids = (resources.get('/subdevices/vs/0') or {}).get( - 'x.com.samsung.da.subdeviceIdList') - # Tolerate anything but a list of strings -- this field is redaction-prone - # (it matches the 'deviceid' substring rule in redact.py) and the existing - # airconditioner_fac_bora fixture carries the literal string - # '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a - # crash -- issue #177 is additive, it must never break an already-working - # single-climate-entity device. - ids = raw_ids if isinstance(raw_ids, list) else [] - for sub_id in sorted(i for i in ids if isinstance(i, str) and i): + def _probe_prefixed(sub_id: str) -> None: + """Materialize one UUID-prefixed subdevice candidate -- shared by + Pattern B (ids from subdeviceIdList) and Pattern C (ids from + /oic/res link prefixes) below, which differ only in where the UUID + came from.""" seed = (sub_id, 'device', '0') batch = _get_batch(sess, seed) _probed(_seed_href(seed), batch) @@ -356,7 +367,7 @@ def enumerate_subdevices( subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed) fetched.update(normalize_seed_batch(subdevice, batch)) subdevices.append(subdevice) - continue + return # Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this # pattern was built against -- turns out not to always expose its own # `//device/0` Collection either, so "every prefixed subdevice @@ -393,12 +404,50 @@ def enumerate_subdevices( flat_hrefs.append(href) fetched[actual] = rep if not flat_hrefs: - continue + return subdevices.append(Subdevice( kind='prefixed', key=sub_id, seed_path=(), flat_hrefs=tuple(flat_hrefs), )) + # --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------ + raw_ids = (resources.get('/subdevices/vs/0') or {}).get( + 'x.com.samsung.da.subdeviceIdList') + # Tolerate anything but a list of strings -- this field is redaction-prone + # (it matches the 'deviceid' substring rule in redact.py) and the existing + # airconditioner_fac_bora fixture carries the literal string + # '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a + # crash -- issue #177 is additive, it must never break an already-working + # single-climate-entity device. + ids = raw_ids if isinstance(raw_ids, list) else [] + listed = sorted(i for i in ids if isinstance(i, str) and i) + for sub_id in listed: + _probe_prefixed(sub_id) + + # --- Pattern C: UUID prefix advertised only via /oic/res ---------------- + # (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third + # multidevice shape: the board answers numofsubdevice='2' on + # /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList + # -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). + # The only trace of the sibling is a UUID-prefixed link in /oic/res + # itself: the x.com.samsung.da.multidevice link, + # '//multidevice/vs/0' on the reporting board. Its washer tree + # answers a full Collection at //device/0, exactly Pattern B's + # transform -- so treat every UUID path prefix seen in /oic/res as a + # prefixed-subdevice candidate (minus ones subdeviceIdList already + # named). Probing is the same tolerated-404 RETRIEVE as everything else + # here, and discover_partitioned's entity-level liveness gate still + # decides materialization, so a board that advertises a UUID link + # without a live sibling behind it contributes nothing. + linked = sorted({ + 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 + } - set(listed)) + for sub_id in linked: + _probe_prefixed(sub_id) + # --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) -------------- indices = sorted({ int(m.group(1)) diff --git a/tests/fixtures/washer_dryer_onebody_awm_device.json b/tests/fixtures/washer_dryer_onebody_awm_device.json new file mode 100644 index 0000000..0243fe7 --- /dev/null +++ b/tests/fixtures/washer_dryer_onebody_awm_device.json @@ -0,0 +1,1364 @@ +{ + "seeds_note": "device0, oic_res, seeds and probes are real, captured live from an AWM-WW-AID-26-ONEBODY (Samsung 'One Body' washer+dryer combo, One UI 7.0 Laundry Hub, Tizen Lite 5.0, KR market). The board advertises x.com.samsung.da.numofsubdevice='2' on /multidevice/vs/0 but has NO /subdevices/vs/0 (no subdeviceIdList; Pattern B doesn't match) and /device/1 and /device/2 both 4.04 (Pattern A doesn't match). The washer subdevice's UUID appears only as the href prefix of the x.com.samsung.da.multidevice link in /oic/res ('/58b7d338-15c5-97d3-b562-000000000001/multidevice/vs/0'); GET /58b7d338-15c5-97d3-b562-000000000001/device/0 answers the washer's own full Collection batch (model ..._WF80H vs the main tree's ..._DV80H27H). See issue #241. Serials/otnDUID/MACs/di scrubbed.", + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4165000000", + "x.com.samsung.da.countryCode": "KR" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.courseTable": "Table_03", + "x.com.samsung.da.st.dryerMode": "Table_03_Course_01" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10", + "x.com.samsung.da.labelScanCourse": "FB_00" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "true", + "x.com.samsung.da.modelCode": "M(DV80H20BBY),W(DV80H20BBY)", + "x.com.samsung.da.salesLocation": "10: Asia", + "x.com.samsung.da.deviceKeepAlive": "Connect" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_0153031B3B1D191A3A1C373C3D38060D24282C0C", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_01551628" + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "On" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "UpdateAllow_NotAllowed", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBell_Nothing", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "FreezeProtectionAlarm_On", + "DetergentAlarm_Off", + "WrinklePreventRunning_Off", + "FreezeProtectionMode_Off", + "SendToDevice_Off", + "DrumLight_Off", + "EmptyingTheBucket_Nothing", + "TextureLevel_None", + "SavingMode_Off", + "LaundryManageFinish_Custom", + "UserLocation_Indoor", + "LaundryOutTime_158", + "WashingTimes_2", + "DrumCleanProposal_10", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentType_0", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_249", + "LaundryPlannerUserSetTime_0", + "LaundryPlannerMicomSetTime_65535", + "DeviceType_0131", + "Course_01", + "MixedLoadBellSet_020202FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "GMT_12", + "DryTimeSet_FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000050016FE060016FE020016FE080009000000000000000000", + "EnergyLevelSet_05030405040502040202050202030204040202050203", + "MostUsed_53D31E", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F", + "SavingModeCondition_01010204531D03380300", + "SpecialProductContainer_01", + "ProgressTimeSet_B23A20B4003C", + "UsagesDB_ok", + "EnergyKW_396", + "TimeSync_NotSupported", + "DrumCleanLog_Empty", + "GeoFenceAlarm" + ], + "x.com.samsung.da.supportedOptions": [ + "101D31E53D31E03D31E4FD3081BD3183BD0001DD31819D2041AD1023AD4101CD30837D2043CD2043DD20438D10206D0000DD00024D00028D0002CD0000CD000" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AWM-WW-AID-26-ONEBODY|80014641|30010101001A1100CAC3039E00830300", + "x.com.samsung.da.description": "AWM-WW-AID-26-ONEBODY_DV80H27H/DC91-00145A_305B", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "DZB", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "AWM-WW-AID-26-ONEBODY|80014641|30010101001A1100CAC3039E00830300(VER:B121)(DATE:260114.055015)", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "00057A26011421", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_80014641260122400FFFFFFFFFFFFFFFFFFFFFFFFFFF(_30000000)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "00146A26012240,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.number": "00145A26031130,00156B25120805", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_800145412603113001FFFF80015642251208050AFFFF(NOTHING)" + }, + { + "x.com.samsung.da.number": "00069A25102104,00068A25102104", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Firmware_3_DB_8000694125102104015FFF8000684125102104012FFF(NOTHING)" + } + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "0", + "x.com.samsung.da.cumulativeDate": "1785567600", + "x.com.samsung.da.cumulativeDateUTC": "1785535200", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Damp", + "Less", + "Normal", + "More" + ], + "x.com.samsung.da.dryTime": "00:00:00", + "x.com.samsung.da.supportedDryTime": [ + "00:00:00", + "00:20:00", + "00:30:00", + "00:40:00", + "00:50:00", + "01:00:00", + "01:30:00", + "02:00:00", + "02:20:00", + "02:30:00", + "03:00:00", + "03:50:00", + "04:00:00" + ], + "x.com.samsung.da.dryerType": "Electricity", + "x.com.samsung.da.currentAvailableDryLevel": "00", + "x.com.samsung.da.currentAvailableDryTime": "000000", + "x.com.samsung.da.currentAvailableSubOptions": "00" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.supportedProgress": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "x.com.samsung.da.remainingTime": "04:09:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progressPercentageWashing": "0", + "x.com.samsung.da.progressPercentageDrying": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00" + } + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Volume_Off", + "Volume_Low", + "Volume_Med", + "Volume_High" + ], + "setBuzzerSound": "Volume_Low", + "supportedFinishSound": [ + "FinishSound_1", + "FinishSound_2", + "FinishSound_3" + ], + "setFinishSound": "FinishSound_3" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/energy/planner/vs/0", + "rep": {} + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1" + ], + "energySavingInfo": [ + { + "aiLevel": "1", + "maxEnergySavingRate": "45" + } + ] + } + }, + { + "href": "/wm/displaymode/state/vs/0", + "rep": { + "detailUserMode": "USERMODE_NORMAL", + "entryMode": "STARTUP_DEFINED_NOT_YET", + "nonUsermodeOperationState": "Ready" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Laundry Hub", + "osVersion": "5.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "AWM-WW-AID-26-ONEBODY", + "versions": [ + "30260114" + ], + "visVersion": "260114" + }, + { + "type": "Micom", + "modelId": "813180014541FFFFFFFF", + "versions": [ + "26031130", + "FFFFFFFF" + ], + "visVersion": "260311" + }, + { + "type": "Micom", + "modelId": "813180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "813180014642FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "815180015341FFFFFFFF", + "versions": [ + "26012609", + "FFFFFFFF" + ], + "visVersion": "260126" + }, + { + "type": "Micom", + "modelId": "815180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "815180014642FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "013180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "01318001454180015642", + "versions": [ + "26031130", + "25120805" + ], + "visVersion": "260311" + }, + { + "type": "Micom", + "modelId": "01318000694180006841", + "versions": [ + "25102104", + "25102104" + ], + "visVersion": "251021" + }, + { + "type": "Micom", + "modelId": "015180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "01518001534180015642", + "versions": [ + "26012609", + "25120805" + ], + "visVersion": "260126" + }, + { + "type": "Micom", + "modelId": "015180002241FFFFFFFF", + "versions": [ + "25101305", + "FFFFFFFF" + ], + "visVersion": "251013" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G", + "5G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ], + "oic_res": [ + { + "di": "REDACTED", + "links": [ + { + "href": "/oic/sec/doxm", + "rt": [ + "oic.r.doxm" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/sec/pstat", + "rt": [ + "oic.r.pstat" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/d", + "rt": [ + "oic.wk.d", + "oic.d.dryer" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/oic/p", + "rt": [ + "oic.wk.p" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/58b7d338-15c5-97d3-b562-000000000001/multidevice/vs/0", + "rt": [ + "x.com.samsung.da.multidevice" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/EasySetupResURI", + "rt": [ + "oic.r.easysetup" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 3, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/WiFiConfResURI", + "rt": [ + "oic.wk.wifi" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/CoapCloudConfResURI", + "rt": [ + "oic.wk.cloudserver" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/DevConfResURI", + "rt": [ + "oic.wk.devconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/devicelog/connectioninfo", + "rt": [ + "x.com.samsung.devicelog" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/devicelog/file", + "rt": [ + "x.com.samsung.devicelog.file" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/calmconnection/command", + "rt": [ + "x.com.samsung.calmconnection.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/sec/provisioninginfo", + "rt": [ + "x.com.samsung.provisioninginfo" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/sec/accesspointlist", + "rt": [ + "x.com.samsung.accesspointlist" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/autoreconnect/ap", + "rt": [ + "x.com.samsung.autoreconnect.ap" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + } + ] + } + ], + "seeds": { + "/58b7d338-15c5-97d3-b562-000000000001/device/0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4165000000", + "x.com.samsung.da.countryCode": "KR" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR", + "en_US" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.courseTable": "Table_02", + "x.com.samsung.da.st.washerMode": "Table_02_Course_01" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10", + "x.com.samsung.da.labelScanCourse": "FB_00" + } + }, + { + "href": "/water/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeWater": "0" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "true", + "x.com.samsung.da.optimalSchedulingRequest": "Clear", + "x.com.samsung.da.modelCode": "M(WF80H24GBY),W(WF80H24GBY)", + "x.com.samsung.da.salesLocation": "10: Asia", + "x.com.samsung.da.deviceKeepAlive": "Connect" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_01B50496A6060A5758200C1A165C55661BB4085F29", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_0129" + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "UpdateAllow_NotAllowed", + "AiOption_On", + "DryerConnect_On", + "LaundryPlus_Off", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "FreezeProtectionAlarm_On", + "DetergentAlarm_Off", + "SoftenerAlarm_Off", + "BubbleSoak_Off", + "FreezeProtectionMode_Off", + "SendToDevice_Off", + "WeatherPush_On", + "DrumLight_Off", + "TextureLevel_None", + "SavingMode_Off", + "LaundryManageFinish_Custom", + "UserLocation_Indoor", + "LaundryOutTime_158", + "WashingTimes_6", + "DrumCleanProposal_40", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentType_0", + "DetergentTotal_0", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_198", + "AvailableDelayTime_49", + "LaundryPlannerUserSetTime_0", + "LaundryPlannerMicomSetTime_65535", + "DeviceType_0151", + "Course_01", + "GMT_12", + "BubbleSoakSet_F0F0F000F0F0F00000F0F0F00000000000F0000000", + "EnergyLevelSet_050403010204020402030505020202020203020101", + "MostUsed_01843E933FA57F00000000000000", + "SavingModeCondition_0101021201B50496A6060A5758200C1A165C55661BB4030149022830", + "DispenserInputAmountSet_000000000000000000000000000000000000000000", + "CourseDetergentAmount_FE", + "CourseSoftenerAmount_FE", + "SpecialProductContainer_00", + "DetergentAverageUsageCapacityInfo_00000320", + "SoftenerCustomAverageUsageCapacityInfo_00000226", + "LaundryPlusAvailableSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FFE", + "ProgressTimeSet_42021C82010EA20294", + "UsagesDB_ok", + "EnergyKW_396", + "TimeSync_NotSupported", + "DrumCleanLog_Empty", + "GeoFenceAlarm" + ], + "x.com.samsung.da.supportedOptions": [ + "301843E933FA57FB5843E933FA57F04811E913FA57F96841E930FA30FA6857E943FA57F06811E933FA53F0A847E933FA47F5783089308A20458841E930FA30F208640933CA57F0C857E933FA57F1A811E913FA57F1683089308A4105C830E930FA41F5581029308A30866831E933FA57F1B84109308A640B4811E930FA30F088000913FA57F5F80009000A57C2985209204A640" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AWM-WW-AID-26-ONEBODY|80014641|20010001001A2342DAC30296009B0300", + "x.com.samsung.da.description": "AWM-WW-AID-26-ONEBODY_WF80H/DC91-00153A_0174", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "WZB", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "AWM-WW-AID-26-ONEBODY|80014641|20010001001A2342DAC30296009B0300(VER:B121)(DATE:260114.055015)", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "00057A26011421", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_80014641260122400FFFFFFFFFFFFFFFFFFFFFFFFFFF(_30000000)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "00146A26012240,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.number": "00153A26012609,00156B25120805", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_800153412601260904FFFF80015642251208050AFFFF(NOTHING)" + }, + { + "x.com.samsung.da.number": "00022A25101305,FFFFFFFFFFFFFF", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Firmware_3_DB_8000224125101305042FFFFFFFFFFFFFFFFFFFFFFFFF(NOTHING)" + } + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "0", + "x.com.samsung.da.cumulativeDate": "1785567600", + "x.com.samsung.da.cumulativeDateUTC": "1785535200", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "40", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "90" + ], + "x.com.samsung.da.spinLevel": "High", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "ExtraLow", + "Low", + "Medium", + "High", + "ExtraHigh" + ], + "x.com.samsung.da.rinseCycles": "3", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.currentAvailableTemperature": "00", + "x.com.samsung.da.currentAvailableRinse": "00", + "x.com.samsung.da.currentAvailableSpin": "00", + "x.com.samsung.da.currentAvailableSubOptions": "00" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.supportedProgress": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "x.com.samsung.da.remainingTime": "00:49:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progressPercentageWashing": "0", + "x.com.samsung.da.progressPercentageDrying": "0", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00" + } + }, + { + "href": "/buzzersound/vs/0", + "rep": { + "supportedBuzzerSound": [ + "Volume_Off", + "Volume_Low", + "Volume_Med", + "Volume_High" + ], + "setBuzzerSound": "Volume_Low", + "supportedFinishSound": [ + "FinishSound_1", + "FinishSound_2", + "FinishSound_3" + ], + "setFinishSound": "FinishSound_3" + } + }, + { + "href": "/rm/framemems/vs/0", + "rep": { + "rawResultControl": "EEEE", + "rawDataLevelConfig": "0", + "rawDataName": "WMFrameMems", + "rawDataDescription": "WM_FrameMems_Sensing_RPM_Control" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": {} + }, + { + "href": "/energy/planner/vs/0", + "rep": { + "plan": "none", + "data": { + "start": "0000-00-00T00:00:00.000Z", + "end": "0000-00-00T00:00:00.000Z", + "interval": 0, + "timeWeightRatio": 0, + "values": [ + 0 + ] + } + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "0", + "supportedAiLevel": [ + "1" + ], + "energySavingInfo": [ + { + "aiLevel": "1", + "maxEnergySavingRate": "70" + } + ] + } + }, + { + "href": "/wm/displaymode/state/vs/0", + "rep": { + "detailUserMode": "USERMODE_NORMAL", + "entryMode": "STARTUP_DEFINED_NOT_YET", + "nonUsermodeOperationState": "Ready" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Laundry Hub", + "osVersion": "5.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "AWM-WW-AID-26-ONEBODY", + "versions": [ + "30260114" + ], + "visVersion": "260114" + }, + { + "type": "Micom", + "modelId": "813180014541FFFFFFFF", + "versions": [ + "26031130", + "FFFFFFFF" + ], + "visVersion": "260311" + }, + { + "type": "Micom", + "modelId": "813180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "813180014642FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "815180015341FFFFFFFF", + "versions": [ + "26012609", + "FFFFFFFF" + ], + "visVersion": "260126" + }, + { + "type": "Micom", + "modelId": "815180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "815180014642FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "013180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "01318001454180015642", + "versions": [ + "26031130", + "25120805" + ], + "visVersion": "260311" + }, + { + "type": "Micom", + "modelId": "01318000694180006841", + "versions": [ + "25102104", + "25102104" + ], + "visVersion": "251021" + }, + { + "type": "Micom", + "modelId": "015180014641FFFFFFFF", + "versions": [ + "26012240", + "FFFFFFFF" + ], + "visVersion": "260122" + }, + { + "type": "Micom", + "modelId": "01518001534180015642", + "versions": [ + "26012609", + "25120805" + ], + "visVersion": "260126" + }, + { + "type": "Micom", + "modelId": "015180002241FFFFFFFF", + "versions": [ + "25101305", + "FFFFFFFF" + ], + "visVersion": "251013" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G", + "5G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] + }, + "probes": { + "/multidevice/vs/0": { + "rt": [ + "x.com.samsung.da.multidevice" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.numofsubdevice": "2" + } + } +} \ No newline at end of file diff --git a/tests/test_onebody_washer_subdevice.py b/tests/test_onebody_washer_subdevice.py new file mode 100644 index 0000000..b4e643b --- /dev/null +++ b/tests/test_onebody_washer_subdevice.py @@ -0,0 +1,92 @@ +"""Pattern C subdevice discovery: AWM-WW-AID-26-ONEBODY washer+dryer combo +(issue #241). + +The fixture is a live capture from the reporting board (see its +`seeds_note`): the master tree is the dryer (`oic.d.dryer`, +`..._DV80H27H`), and a complete washer tree (`..._WF80H`) answers at +`//device/0`, where the UUID appears *only* as the path prefix of the +`x.com.samsung.da.multidevice` link in `/oic/res` -- no `subdeviceIdList` +(Pattern B's signal), no `/device/` sibling (Pattern A's). +""" +from tests.conftest import FakeCoapSession, _discover_full, _load_device_full + +FIXTURE = 'washer_dryer_onebody_awm' +WASHER_UUID = '58b7d338-15c5-97d3-b562-000000000001' + + +def _discover(): + resources, oic_res, seeds = _load_device_full(FIXTURE) + return _discover_full(resources, oic_res, seeds) + + +def test_washer_subdevice_materializes_from_oic_res_uuid_link(): + bound, materialized, skipped, full_resources, device_type_name = _discover() + + assert [s.key for s in materialized] == [WASHER_UUID] + sub = materialized[0] + assert sub.kind == 'prefixed' + assert sub.seed_path == (WASHER_UUID, 'device', '0') + # Collection mode, not the issue-#205 flat fallback. + assert sub.flat_hrefs == () + + +def test_washer_subdevice_probe_only_fires_for_the_advertised_uuid(): + """Pattern C must not invent candidates: exactly one prefixed seed is + probed (the UUID from the /oic/res multidevice link), alongside the + bounded speculative Pattern A probes this board 4.04s.""" + from custom_components.localthings.registry.subdevices import ( + enumerate_subdevices, + ) + + resources, oic_res, seeds = _load_device_full(FIXTURE) + probes: dict[str, bool] = {} + sess = FakeCoapSession(seeds) + candidates, _extra = enumerate_subdevices( + sess, resources, oic_res, probe_log=probes.__setitem__, + ) + + assert probes[f'/{WASHER_UUID}/device/0'] is True + assert probes['/device/1'] is False + assert probes['/device/2'] is False + # No flat-fallback flood: the seed Collection answered, so no per-href + # probes under the prefix beyond the seed itself. + prefixed_probes = [h for h in probes if h.startswith(f'/{WASHER_UUID}/')] + assert prefixed_probes == [f'/{WASHER_UUID}/device/0'] + assert [c.key for c in candidates] == [WASHER_UUID] + + +def test_washer_subdevice_binds_its_own_primary_entities(): + """The washer tree carries its own /operational/state and /power -- the + liveness gate must see live primary entities, and the bound set must + carry washer-side keys under the subdevice key prefix.""" + bound, materialized, _skipped, _full, _name = _discover() + sub = materialized[0] + + sub_keys = {b.desc.key for b in bound if b.subdevice.key == sub.key} + assert sub_keys, 'washer subdevice bound no entities at all' + # Its own operational state and its own power switch/sensor -- the two + # things SmartThings cloud splits this unit over. + assert any('machine_state' in k for k in sub_keys), sub_keys + assert any('power' in k for k in sub_keys), sub_keys + + +def test_master_dryer_entities_unchanged_by_washer_discovery(): + """Additive only: the master (dryer) side must keep binding the same + entity keys with and without the washer candidate materializing.""" + resources, _oic_res, _seeds = _load_device_full(FIXTURE) + + # Without any subdevices (oic_res withheld, seeds empty -> washer never + # discovered). + bound_solo, materialized_solo, _s, _f, _n = _discover_full( + resources, [], {}, + ) + assert materialized_solo == [] + solo_keys = {(b.href, b.desc.key) for b in bound_solo} + + bound, materialized, _s2, _f2, _n2 = _discover() + sub = materialized[0] + main_keys = { + (b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key + } + + assert main_keys == solo_keys From e92690551785f81f0377aeec1413b7fbb40ca1ad Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 2 Aug 2026 21:40:34 +0000 Subject: [PATCH 2/2] fix: dedupe Pattern C's UUID-prefix candidates against Pattern B (#242 review) Both the /subdevices/vs/0 subdeviceIdList (Pattern B) and an /oic/res link's UUID prefix (Pattern C) can name the same physical subdevice -- TP2X_FAC_BORA_21K, the Pattern B reporter's own board, does. Filtering the two candidate lists against each other with a plain set difference missed this when the two sources disagree on the UUID's case, letting the same subdevice get probed and materialized twice under two different keys. Move the guard into _probe_prefixed itself, keyed on a case-normalized id, so neither pattern can add a candidate the other already claimed regardless of casing. --- .../localthings/registry/subdevices.py | 25 +++++++++--- tests/test_subdevices.py | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 4e11e78..28ed369 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -350,6 +350,13 @@ def enumerate_subdevices( """ subdevices: list[Subdevice] = [] fetched: dict[str, dict] = {} + # Case-insensitive -- the same UUID can reach here once from + # subdeviceIdList and once from an /oic/res link prefix with different + # casing (Samsung's own fields disagree on this elsewhere too, e.g. the + # redaction-prone subdeviceIdList handling below), and probing it twice + # would materialize the same physical subdevice as two Subdevice + # candidates under two different keys. + probed_ids: set[str] = set() def _probed(seed_href: str, batch: dict) -> None: if probe_log is not None: @@ -360,6 +367,9 @@ def enumerate_subdevices( Pattern B (ids from subdeviceIdList) and Pattern C (ids from /oic/res link prefixes) below, which differ only in where the UUID came from.""" + if sub_id.lower() in probed_ids: + return + probed_ids.add(sub_id.lower()) seed = (sub_id, 'device', '0') batch = _get_batch(sess, seed) _probed(_seed_href(seed), batch) @@ -434,17 +444,20 @@ def enumerate_subdevices( # '//multidevice/vs/0' on the reporting board. Its washer tree # answers a full Collection at //device/0, exactly Pattern B's # transform -- so treat every UUID path prefix seen in /oic/res as a - # prefixed-subdevice candidate (minus ones subdeviceIdList already - # named). Probing is the same tolerated-404 RETRIEVE as everything else - # here, and discover_partitioned's entity-level liveness gate still - # decides materialization, so a board that advertises a UUID link - # without a live sibling behind it contributes nothing. + # prefixed-subdevice candidate. Probing is the same tolerated-404 + # RETRIEVE as everything else here, and discover_partitioned's + # entity-level liveness gate still decides materialization, so a board + # that advertises a UUID link without a live sibling behind it + # contributes nothing. _probe_prefixed's probed_ids guard -- not a set + # difference against `listed` here -- is what keeps an id already named + # by subdeviceIdList from being probed and materialized a second time, + # since the two sources can disagree on that UUID's case. linked = sorted({ 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 - } - set(listed)) + }) for sub_id in linked: _probe_prefixed(sub_id) diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index d501df6..19d3ebd 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -451,6 +451,45 @@ def test_enumerate_both_patterns_checked_independently(): ] +def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_not_duplicated(): + """A board can carry a UUID that is both listed in subdeviceIdList + (Pattern B's signal) *and* advertised as an /oic/res link prefix + (Pattern C's) -- the TP2X_FAC_BORA_21K reporter's own board does this. + The two signals must resolve to one candidate, not two.""" + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + } + oic_res = [{'di': 'a', 'links': [ + {'href': f'/{_UUID}/multidevice/vs/0'}, + ]}] + sess = _FakeSession({ + (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], + }) + subdevices, _extra = enumerate_subdevices(sess, resources, oic_res) + assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)] + + +def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated(): + """subdeviceIdList and an /oic/res link prefix can name the same UUID + with different casing -- the match must be case-insensitive, or the + same physical subdevice materializes twice under two different keys.""" + upper_uuid = _UUID.upper() + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [upper_uuid]}, + } + oic_res = [{'di': 'a', 'links': [ + {'href': f'/{_UUID}/multidevice/vs/0'}, + ]}] + sess = _FakeSession({ + (upper_uuid, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], + (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], + }) + subdevices, _extra = enumerate_subdevices(sess, resources, oic_res) + assert len(subdevices) == 1 + assert subdevices[0].kind == 'prefixed' + assert subdevices[0].key.lower() == _UUID + + # --------------------------------------------------------------------------- # discover_partitioned # ---------------------------------------------------------------------------