From bae1ac43370b1f0ff5573c4a69da2f54825103bf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:44:23 +0000 Subject: [PATCH] Fix dryer detection when description pairs two model numbers (issue #79) DVE50A8600V/A3 reports description 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080' -- a paired listing of two related model numbers (DVE50A8800 and DVE50A8600) joined by an underscore, rather than the usual single trailing consumer-model token. for_device_by_model only ever checked the literal last underscore segment ('8600', which has no recognizable 2-letter prefix on its own), so the real 'DV' token one segment earlier was never reached and the device fell back to 'unknown' with only common capabilities -- no dry level, cycle, or wrinkle-prevent entities. Replace the single last-segment extraction with _consumer_model_key, which scans segments from the end and returns the first one that resolves. Behavior is unchanged for every existing single-token description (the last segment still matches first); it just keeps looking when that segment doesn't. --- .../localthings/registry/by_type/__init__.py | 23 +- tests/fixtures/dryer_dve50a8600_device.json | 391 ++++++++++++++++++ tests/fixtures/golden/dryer_dve50a8600.json | 24 ++ tests/test_by_type.py | 35 ++ tests/test_golden_regression.py | 17 + 5 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/dryer_dve50a8600_device.json create mode 100644 tests/fixtures/golden/dryer_dve50a8600.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..f191df4 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -87,6 +87,26 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { } +def _consumer_model_key(description: str) -> Optional[str]: + """Registry key from the consumer-model token in `description`, or None. + + Usually that token is the last '_'-delimited segment before any + '/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4'). + But issue #79's dryer pairs two model numbers in one description -- + '..._DVE50A8800_8600/DC92-...' -- so the true consumer token + ('DVE50A8800') sits one segment *before* the actual last segment + ('8600', a bare second model number with no recognizable prefix). Scan + segments from the end and take the first one that resolves, rather + than assuming the last segment is always it. + """ + segments = (description or '').split('/', 1)[0].split('_') + for segment in reversed(segments): + key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper()) + if key is not None: + return key + return None + + def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]: """Fallback device-type detection for hardware that never reports oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has @@ -100,8 +120,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi DeviceRegistry if the consumer-model code or modelNum resolves to a known type, None otherwise. """ - token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1] - key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper()) + key = _consumer_model_key(description) if key is None and '_REF_' in (model_num or ''): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and diff --git a/tests/fixtures/dryer_dve50a8600_device.json b/tests/fixtures/dryer_dve50a8600_device.json new file mode 100644 index 0000000..cfc135b --- /dev/null +++ b/tests/fixtures/dryer_dve50a8600_device.json @@ -0,0 +1,391 @@ +{ + "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": "0000000000", + "x.com.samsung.da.countryCode": "US" + } + }, + { + "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" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0152", + "UpdateAllow_NotAllowed", + "Course_01", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_3", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_39", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B20870B400B4", + "WrinklePreventRunning_Off", + "DryTime_0", + "SendToDevice_Off", + "GMT_F6", + "DryTimeSet_0A00000000500A005A00000000000000001E0A005A00000000000000001414005A000000000000000000000000000000000000000000000000000000000000000000000000140A005A280A005A", + "EnergyLevelSet_0504040504040205020204040305020203030103", + "MostUsed_068A3ED000", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "MixedLoadBellSet_02FF02FFFF02FFFFFF0202FF02FFFF02FFFFFF", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "2018410D33E06833ED0002F8520D33E178520D3083E8520D000078102D33E338520D520358520D000348520D0000E8520D33E058520D33E308410D308318410D33E098410D3080F8204D308328308D33E2E8102D308368000D0000D8102D000" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "On" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "2323800", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784991600", + "x.com.samsung.da.cumulativeDateUTC": "1785009600" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-05:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080", + "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": "310", + "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": "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02986A260118(A182)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20286441210514150FFFFF202835412105131904FFFF(01522028644120283541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02864A21051415,02835A21051319", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:15:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:15:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Drying", + "Cooling", + "Finish" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "2026-03-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01522028644120283541", + "versions": [ + "21051415", + "21051319" + ], + "visVersion": "210514" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "en_US", + "ko_KR", + "fr_CA", + "es_MX", + "pt_BR" + ], + "x.com.samsung.da.setLanguage": "en_US" + } + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_03_Course_01", + "x.com.samsung.da.st.courseTable": "Table_03" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Chicago", + "offset": "-05:00", + "DST": "ON" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.waterTemperature": "Medium", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "ExtraLow", + "Low", + "MediumLow", + "Medium", + "High" + ], + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Damp", + "Less", + "Normal", + "More", + "Very" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_0106", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_0101" + } + }, + { + "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" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(DVE50A8600V/A3)" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/dryer_dve50a8600.json b/tests/fixtures/golden/dryer_dve50a8600.json new file mode 100644 index 0000000..da10e04 --- /dev/null +++ b/tests/fixtures/golden/dryer_dve50a8600.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..05cbcf6 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -111,6 +111,27 @@ class TestWasherRegistry: assert href in registry.capabilities, f"{href} missing from washer registry" +class TestConsumerModelKey: + def test_finds_key_in_last_segment(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C') == 'washer' + + def test_finds_key_before_a_trailing_unrecognized_segment(self): + """Issue #79: 'DVE50A8800_8600' pairs two model numbers -- the real + consumer token is the second-to-last segment, not the last.""" + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key( + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080') == 'dryer' + + def test_ignores_everything_after_first_slash(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C/DW9000_board') == 'washer' + + def test_none_when_no_segment_matches(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('ARTIK051_DONGLE_REF') is None + + class TestForDeviceByModel: """Fallback device-type detection for hardware without oneUiVersion.""" @@ -142,6 +163,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'dryer' + def test_dryer_dve50a8600_paired_model_numbers_in_description(self): + """Issue #79: description pairs two model numbers + ('..._DVE50A8800_8600/DC92-...'), so the 'DV' consumer token is one + segment before the literal last segment ('8600', which has no + recognizable prefix on its own). The old last-segment-only check + fell through to 'unknown' here.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000', + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080', + ) + assert reg is not None + assert reg.name == 'dryer' + def test_dishwasher_not_misdetected_as_washer(self): """Dishwasher's modelNum contains the substring 'WW' -- must not be misrouted by a naive substring match.""" diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..b84f39b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -73,6 +73,23 @@ def test_registry_reproduces_golden_state_keys_for_dryer(): ) +def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): + """DVE50A8600V/A3 (issue #79) -- description pairs two model numbers + ('..._DVE50A8800_8600/...'), so the true 'DV' consumer-model token sits + one segment before the actual last segment ('8600'). The old + last-segment-only check missed it and fell back to 'unknown'; resolved + via _consumer_model_key scanning segments from the end.""" + from tests.conftest import _load_device + resources = _load_device('dryer_dve50a8600') + golden = json.loads((GOLDEN / 'dryer_dve50a8600.json').read_text()) + state_keys = _new_state_keys('dryer_dve50a8600', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_airconditioner(): from tests.conftest import _load_device resources = _load_device('airconditioner')