From 8ac1d11d1773c57c214c219598fc283a738d4767 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 04:15:08 +0000 Subject: [PATCH] Add WA (top-load washer) consumer-model prefix (issue #106) WA8000T reports no oneUiVersion and used the 'WA' consumer-model prefix, unmapped in _CONSUMER_PREFIX_TO_KEY (only WW/WD/WF/WV were covered), so it fell into the unknown-device-type fallback. Adding a bare 'WA' entry collided with the unrelated '_WAC_' (Window Air Conditioner, issue #87) board-family token: some devices report description == modelNum, so 'WAC' shows up as its own description segment and 'WAC'[:2] == 'WA' matched the new washer prefix before the more specific '_WAC_' modelNum check ever ran. Fixed by reordering for_device_by_model() to check board-family modelNum tokens first and the fuzzier 2-letter consumer-model-prefix scan only as a fallback, rather than patching the prefix matching itself (an earlier attempt -- requiring a digit immediately after the prefix -- broke issue #79's real DVE50A8800 case, which has no digit there either). Added a regression test pinning the WAC/WA disambiguation directly. Confirmed against the issue #106 diagnostics dump with zero unbound hrefs. --- .../localthings/registry/by_type/__init__.py | 20 +- tests/fixtures/golden/washer_wa8000t.json | 26 ++ tests/fixtures/washer_wa8000t_device.json | 314 ++++++++++++++++++ tests/test_by_type.py | 25 ++ tests/test_golden_regression.py | 15 + 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/golden/washer_wa8000t.json create mode 100644 tests/fixtures/washer_wa8000t_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index cef9377..956aeb5 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -86,6 +86,7 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { 'WD': 'washer', 'WF': 'washer', 'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19 + 'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106 'DV': 'dryer', 'DW': 'dishwasher', } @@ -118,6 +119,13 @@ def _consumer_model_key(description: str) -> Optional[str]: ('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. + + Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner + board-family token, issue #87) also starts with 'WA' (the top-load-washer + prefix, issue #106) at this granularity. for_device_by_model() calls the + board-family modelNum checks first and this function only as a fallback, + so that ambiguity resolves correctly without this function needing to + know about unrelated device families. """ segments = (description or '').split('/', 1)[0].split('_') for segment in reversed(segments): @@ -140,7 +148,12 @@ 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. """ - key = _consumer_model_key(description) + # Board-family modelNum tokens are checked first, and the fuzzier + # 2-letter consumer-model prefix from `description` only as a fallback + # (see the bottom of this function) -- some board tokens are themselves + # only 2-3 letters long ('WAC', issue #87) and would otherwise collide + # with an unrelated consumer prefix at that granularity ('WA', issue #106). + key = None if key is None and 'REF' in _model_num_segments(model_num): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and @@ -210,6 +223,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # board family's. if key is None and '-COOKTOP-' in (model_num or '').upper(): key = 'induction_cooktop' + # Consumer-model prefix from `description` (washer/dryer/dishwasher) -- + # last, since it's the fuzziest match (a bare 2-letter prefix) and would + # otherwise shadow the more specific board-family tokens above. + if key is None: + key = _consumer_model_key(description) return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/tests/fixtures/golden/washer_wa8000t.json b/tests/fixtures/golden/washer_wa8000t.json new file mode 100644 index 0000000..b0e0ef6 --- /dev/null +++ b/tests/fixtures/golden/washer_wa8000t.json @@ -0,0 +1,26 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "softener_low", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/washer_wa8000t_device.json b/tests/fixtures/washer_wa8000t_device.json new file mode 100644 index 0000000..6ba5c69 --- /dev/null +++ b/tests/fixtures/washer_wa8000t_device.json @@ -0,0 +1,314 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "121100", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1785142800", + "x.com.samsung.da.cumulativeDateUTC": "1785110400" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015C", + "UpdateAllow_NotAllowed", + "Course_01", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "FreezeProtectionAlarm_On", + "WashingTimes_19", + "DrumCleanProposal_20", + "DrumCleanLog_2026-02-04T04:05:54|2026-02-28T08:48:43|2026-03-16T03:15:29|2026-03-26T02:55:05|2026-04-15T05:57:11|2026-04-30T05:37:44|2026-05-14T05:29:20|2026-06-04T01:55:39|2026-06-25T05:21:23|2026-07-11T03:10:21", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_73", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_420780820438A205BE", + "FreezeProtectionMode_Off", + "SendToDevice_Off", + "EnergyLevelSet_0503030303040304010505020204", + "MostUsed_01923FA33F510E60A17340", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2026-02-04T04:05:54|2026-02-28T08:48:43|2026-03-16T03:15:29|2026-03-26T02:55:05|2026-04-15T05:57:11|2026-04-30T05:37:44|2026-05-14T05:29:20|2026-06-04T01:55:39|2026-06-25T05:21:23|2026-07-11T03:10:21", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "501923FA33F510E60A1734050923FA33F510E60A173400F933FA33F510E60A173407B923FA31F520E60A1724006933FA31F510E68A172400C933FA31F520E60A172404F9204A41052046666733308913FA33F510E60A170004A923FA31F520C645175554B923FA31F520C64517555269204A102510664A171114C913FA31F510E624171404D9204A410520466667555" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:13: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", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:13:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A250414(F546)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20233741181128160FFFFF202810412011303403FFFF(015C2023374120281041_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20113034", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2023394400000000032FFFFFFFFFFFFFFFFFFFFFFFFE(015C20233944FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "00000000,FFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.spinLevel": "Medium", + "x.com.samsung.da.supportedSpinLevel": [ + "NoSpin", + "Delicate", + "Low", + "Medium", + "High", + "ExtraHigh" + ], + "x.com.samsung.da.rinseCycles": "2", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.waterValve": "Cold", + "x.com.samsung.da.supportedWaterValve": [ + "None", + "Cold", + "Warm", + "Hot" + ], + "x.com.samsung.da.waterHeight": "0", + "x.com.samsung.da.supportedWaterHeight": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "NoDisplay" + ], + "x.com.samsung.da.washTime": "21", + "x.com.samsung.da.supportedWashTime": [ + "0", + "9", + "15", + "21", + "27", + "50+" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_01", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4137000000", + "x.com.samsung.da.countryCode": "KR" + } + } + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 6ecb818..e135584 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -300,6 +300,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_wac_token_not_shadowed_by_wa_washer_prefix(self): + """Regression: adding the 'WA' consumer-model prefix (issue #106) + must not hijack devices whose description literally equals their + modelNum (e.g. the Window AC family, issue #87) and so contains a + 'WAC' segment -- 'WAC'[:2] == 'WA' would otherwise match the washer + prefix before the more specific '_WAC_' modelNum check ever runs.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000', + 'TP1X_DA_AC_WAC_01001_0000', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_cooktop_via_legacy_model_description(self): """Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP.""" from custom_components.localthings.registry.by_type import for_device_by_model @@ -339,6 +353,17 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'washer' + def test_washer_wa_prefix(self): + """Top-load washers (e.g. WA8000T) use the WA consumer-model prefix + -- issue #106.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000', + 'DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002', + ) + assert reg is not None + assert reg.name == 'washer' + def test_oven_via_oven_token(self): """Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and an unrecognized consumer token ('NV'); it falls back to the '-OVEN-' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 1764c17..8abb316 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -61,6 +61,21 @@ def test_registry_reproduces_golden_state_keys_for_washer(): ) +def test_registry_reproduces_golden_state_keys_for_washer_wa8000t(): + """Top-load washer (WA8000T, issue #106) reports no oneUiVersion and + used the 'WA' consumer-model prefix, previously unmapped in + _CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'.""" + from tests.conftest import _load_device + resources = _load_device('washer_wa8000t') + golden = json.loads((GOLDEN / 'washer_wa8000t.json').read_text()) + state_keys = _new_state_keys('washer_wa8000t', 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_dryer(): from tests.conftest import _load_device resources = _load_device('dryer')