From bae1ac43370b1f0ff5573c4a69da2f54825103bf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:44:23 +0000 Subject: [PATCH 1/9] 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') From 5d7789eb4eaf485ff9c112e533627d35da43c705 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:47:16 +0000 Subject: [PATCH 2/9] Add confirmed washer/dryer cycle code labels (issue #80) Five raw course codes were rendering unlabeled because no translation entry existed for them, confirmed by the reporter selecting each cycle on the physical appliance and reading back the raw code from the entity's state: - washer_cycle_table_02: '52' Eco Cold, '54' Towels, '60' Self Clean+ (a WF50A8600AV/US). '54' shares a display name with the existing '24' Towels -- a different code on the same table legitimately landing on the same label, matching the existing '21'/'65' Colors and '27'/'5E' Rinse+Spin pairs, not a duplicate-in-error. - dryer_cycle_table_03: '01' Normal, '06' Time dry (a DVE50A8600V/A3, the same model added in the previous commit's detection fix). No code changes -- select.py already derives which raw values it normalizes from the shipped catalog, so labelling a code is purely a translations/en.json (mirrored to nl.json) addition. --- .../localthings/registry/capabilities/dryer.py | 5 ++++- .../localthings/registry/capabilities/washer.py | 8 ++++++++ .../localthings/translations/en.json | 5 +++++ .../localthings/translations/nl.json | 5 +++++ tests/test_translations.py | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index f1fcb70..8d5224c 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -42,7 +42,10 @@ DRYER_SETTINGS = Capability( # under entity.select.dryer_cycle (Table_03, DV5000-class, captured # 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 # editCourseList but aren't identified yet -- they render as the raw code -# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected +# until named. Codes '01' Normal and '06' Time dry were confirmed on a +# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical +# appliance and reading back the raw code from the entity's state (issue +# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected # course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 # is ignored for washers. DRYER_COURSE = Capability( diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 615f058..a006c16 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -45,6 +45,14 @@ from .laundry import ( # (issue #22). Combo units carry their own course set, so these codes # don't imply anything about '1F' on a plain washer. # +# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a +# WF50A8600AV/US, verified directly rather than by inference: the reporter +# selected each cycle on the physical appliance and read back the resulting +# raw code from the cycle_select entity's state (issue #80). '54' shares a +# display name with the existing '24' Towels -- a different code on a +# different course table legitimately landing on the same label, not a typo +# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above). +# # No static fallback list of those codes is kept here, deliberately: other # washer models have a different actual course set (a second dump's active # course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..ac199df 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -201,6 +201,8 @@ "dryer_cycle_table_03": { "name": "Cycle", "state": { + "01": "Normal", + "06": "Time dry", "16": "Cotton", "18": "Synthetics", "19": "Delicates", @@ -389,10 +391,13 @@ "37": "Air Wash", "38": "Cotton Dry", "39": "Synthetics Dry", + "52": "Eco Cold", "53": "Heavy Duty", + "54": "Towels", "55": "Activewear", "57": "Delicate", "5e": "Rinse+Spin", + "60": "Self Clean+", "65": "Colors", "66": "Denim", "7c": "Whites", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..9a72317 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -201,6 +201,8 @@ "dryer_cycle_table_03": { "name": "Programma", "state": { + "01": "Normaal", + "06": "Tijdprogramma", "16": "Katoen", "18": "Synthetisch", "19": "Fijne was", @@ -389,10 +391,13 @@ "37": "Air Wash", "38": "Katoen drogen", "39": "Synthetisch drogen", + "52": "Eco koud", "53": "Intensief", + "54": "Handdoeken", "55": "Sportkleding", "57": "Fijn", "5e": "Spoelen+centrifugeren", + "60": "Zelfreinigend+", "65": "Bonte was", "66": "Spijkergoed", "7c": "Witte was", diff --git a/tests/test_translations.py b/tests/test_translations.py index c360ffd..0fa9782 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,3 +168,18 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() + + +def test_issue_80_cycle_codes_are_labelled(): + """Confirmed by the reporter selecting each cycle on the physical + appliance and reading back the raw code from the entity's state -- + regression-lock those five mappings against accidental removal.""" + select_strings = _load("en")["entity"]["select"] + washer_state = select_strings["washer_cycle_table_02"]["state"] + assert washer_state["52"] == "Eco Cold" + assert washer_state["54"] == "Towels" + assert washer_state["60"] == "Self Clean+" + + dryer_state = select_strings["dryer_cycle_table_03"]["state"] + assert dryer_state["01"] == "Normal" + assert dryer_state["06"] == "Time dry" From 2c9fda3db15f4923d3e25d618e30443cb9ff280a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:00:05 +0000 Subject: [PATCH 3/9] Catch the 'Nothing(SVC)' placeholder serial in both fallback sites (issue #83) The ARTIK051_DONGLE_REF firmware family reports the literal string "Nothing(SVC)" as serialNum on every unit -- non-empty, so the existing `if not serial` checks in config_flow.py's _probe_and_validate and coordinator.py's _run_discovery don't catch it. Two such appliances on one install (a fridge and a freezer, each its own dongle) then collide: config_flow gives both the same unique_id and rejects the second as "already configured" (bug 2), and even once that's worked around, device_serial feeds every entity's unique_id too, so the second appliance's entities get silently dropped with "does not generate unique IDs" log lines (bug 4). Add _is_placeholder_serial (duplicated in both modules rather than imported, to avoid pulling config_flow into the runtime coordinator's import graph or vice versa for a two-line check) and treat it the same as an empty serial: fall back to host/port. Type detection (bug 1) and the door sensor's field-name gap (bug 3), also reported in this issue, are already fixed via the claude/device-support-issue-77-freezer branch, which hit the same ARTIK051_DONGLE_REF family from a different report -- not duplicated here. --- custom_components/localthings/config_flow.py | 14 +++++++++++- custom_components/localthings/coordinator.py | 18 ++++++++++++++- tests/localthings/test_config_flow.py | 16 ++++++++++++++ tests/localthings/test_coordinator.py | 23 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 664f170..37a8595 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -197,6 +197,18 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: return _order_candidates(live) +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check here (and the equivalent one in coordinator.py's + `_run_discovery`) doesn't catch it, and two such units get the same + config-entry unique_id / entity unique_ids and collide (issue #83). + """ + return serial.strip().lower().startswith('nothing') + + def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: """Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict.""" import cbor2 @@ -261,7 +273,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: .get('/information/vs/0', {}) .get('x.com.samsung.da.serialNum', '') ) - if not serial: + if not serial or _is_placeholder_serial(serial): serial = f"{host}:{port}" one_ui_version = ( resources diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 171630f..643b822 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -49,6 +49,22 @@ class _NoOpDescriptor: _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check below doesn't catch it, and `device_serial` feeds both + the HA device-registry identifier and every entity's unique_id + (entity.py), so two such units on the same install silently collide + and the second one's entities get dropped (issue #83). Mirrors the + identical helper in config_flow.py's `_probe_and_validate` -- kept + separate rather than imported to avoid pulling the config-flow module + into the runtime coordinator's import graph for a two-line check. + """ + return serial.strip().lower().startswith('nothing') + + class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages one Samsung appliance: session, discovery, polling.""" @@ -312,7 +328,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._unbound_hrefs = unbound serial = info.get('x.com.samsung.da.serialNum', '') - if not serial: + if not serial or _is_placeholder_serial(serial): serial = self._entry.data[CONF_HOST] self.device_serial = serial diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 0f4fee5..20f4270 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -508,3 +508,19 @@ async def test_options_flow_finish_preserves_existing_options( assert result['type'] == FlowResultType.CREATE_ENTRY assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True + + +def test_is_placeholder_serial_catches_nothing_svc(): + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty, + so it must be caught by name, not by the plain `if not serial` check.""" + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('Nothing(SVC)') is True + assert _is_placeholder_serial('nothing(svc)') is True + assert _is_placeholder_serial(' Nothing(SVC) ') is True + + +def test_is_placeholder_serial_accepts_real_serials(): + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('0A1B2C3D4E5F') is False + assert _is_placeholder_serial('') is False diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 0a1b420..bc5f343 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -136,6 +136,29 @@ def test_run_discovery_detects_washer_via_model_fallback( assert coordinator.device_type_name == 'washer' +def test_run_discovery_falls_back_to_host_for_placeholder_serial( + hass: HomeAssistant, mock_entry +) -> None: + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is, + two such units get the same device_serial (which feeds both the HA + device-registry identifier and every entity's unique_id), so the + second one's entities silently collide and get dropped. It must be + treated the same as an empty serial and fall back to the host.""" + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': + 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', + 'x.com.samsung.da.description': 'ARTIK_REF_17K', + 'x.com.samsung.da.serialNum': 'Nothing(SVC)', + }, + '/otninformation/vs/0': {}, + } + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(resources) + assert coordinator.device_serial == mock_entry.data[CONF_HOST] + + def test_run_discovery_detects_cooktop_via_resource_signature( hass: HomeAssistant, mock_entry ) -> None: From b47eeaf7bcd9a56e66cd629613e8b9245fbe2be0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:17:45 +0000 Subject: [PATCH 4/9] Add device support for Bespoke Window AC (issue #87) The AW06C7155EWAZ window air conditioner (modelNum TP1X_DA_AC_WAC_01001_0000) reports no oneUiVersion and uses the '_WAC_' (Window Air Conditioner) modelNum token instead of the '_RAC_'/'_PRAC_' tokens already handled by for_device_by_model. Add a fallback rule for that token, routing it to the existing airconditioner registry. The device's resource surface (mode/convenient/wind/temperature/power/ filter/humidity) is already fully modeled by the airconditioner capability set, so this is purely a detection-routing fix -- confirmed against the issue #87 diagnostics dump with zero unbound hrefs. --- .../localthings/registry/by_type/__init__.py | 9 + .../airconditioner_window_ac_device.json | 523 ++++++++++++++++++ .../golden/airconditioner_window_ac.json | 18 + tests/test_by_type.py | 13 + tests/test_golden_regression.py | 17 + 5 files changed, 580 insertions(+) create mode 100644 tests/fixtures/airconditioner_window_ac_device.json create mode 100644 tests/fixtures/golden/airconditioner_window_ac.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..73a6c95 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -122,6 +122,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # resource (see airconditioner.py's _AC_IGNORED). if key is None and '-CAWW-' in (model_num or '').upper(): key = 'airconditioner' + # Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87) + # report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner) + # token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface + # as the room-AC models above (mode/convenient/wind/temperature/power/ + # filter/humidity all confirmed against the issue #87 dump binding + # cleanly against the existing airconditioner registry once routed + # here) -- no WAC-specific resources needed. + if key is None and '_WAC_' in (model_num or ''): + key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. if key is None and '_TVTL_' in (model_num or ''): diff --git a/tests/fixtures/airconditioner_window_ac_device.json b/tests/fixtures/airconditioner_window_ac_device.json new file mode 100644 index 0000000..c02ae37 --- /dev/null +++ b/tests/fixtures/airconditioner_window_ac_device.json @@ -0,0 +1,523 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": "0", + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8D0D00B4012C3F21064804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2647000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "AI_RAC_KOREA_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "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": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 244.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "244.000000", + "x.com.samsung.da.cumulativePower": "11001", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "20", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "60" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_WAC_01001_0000|10254341|60050000001711014E00402000002000", + "x.com.samsung.da.description": "TP1X_DA_AC_WAC_01001_0000", + "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": "AW0", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02545A260601", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02543A24061800,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "02580A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02553A23031600,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Quiet", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "OnTimer_0", + "OffTimer_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_230", + "Volume_Mute", + "OutdoorTemp_97", + "CoolCapa_0", + "StopAutoClean_Idle", + "Autoclean_Off", + "OptionCode_51320", + "ExtendOptionCode_230093", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_93", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "Off", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AWA-KR-TP1-23-AW7000", + "versions": [ + "15260601" + ], + "visVersion": "260601" + }, + { + "type": "Micom", + "modelId": "042010254341FFFFFFFF", + "versions": [ + "24061800", + "FFFFFFFF" + ], + "visVersion": "240618" + }, + { + "type": "Micom", + "modelId": "042010258041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042010255341FFFFFFFF", + "versions": [ + "23031600", + "FFFFFFFF" + ], + "visVersion": "230316" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8DFFFFFFFF121E121E0000FFFFFFFFFFFF01000000011F0000000000000000001F8C00", + "x.com.samsung.da.id": "WAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "30.0", + "x.com.samsung.da.current": "30.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json new file mode 100644 index 0000000..ea27504 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..0aed644 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_airconditioner_via_wac_token(self): + """Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no + oneUiVersion and a modelNum carrying the '_WAC_' (Window Air + Conditioner) token instead of '_RAC_'/'_PRAC_'; falls back to the + '_WAC_' token in modelNum.""" + 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', + 'AW06C7155EWAZ', + ) + 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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..3d8d413 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -231,6 +231,23 @@ def test_registry_reproduces_golden_state_keys_for_caww_tp2(): ) +def test_registry_reproduces_golden_state_keys_for_window_ac(): + """TP1X_DA_AC_WAC_01001_0000 (issue #87, Bespoke Window AC AW06C7155EWAZ) + reports no oneUiVersion and carries the '_WAC_' (Window Air Conditioner) + token instead of '_RAC_'/'_PRAC_'; resolved via the '_WAC_' modelNum + fallback in for_device_by_model. Otherwise binds cleanly against the + existing airconditioner registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_window_ac') + golden = json.loads((GOLDEN / 'airconditioner_window_ac.json').read_text()) + state_keys = _new_state_keys('airconditioner_window_ac', 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_tp1x_rac(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display light, self-check, mute-once, and a current-limit setting.""" From 89429039fb8c0902750374a10a9f949d5e53ce10 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:29:25 +0000 Subject: [PATCH 5/9] Add device support for Samsung dehumidifiers (issue #88) The AY18CG7500GED dehumidifier (modelNum TP1X_DA_AC_DHM_01001_0000) shares the DA_AC_ board family with the room-AC models but carries the '_DHM_' token instead of '_RAC_'/'_PRAC_'/'_WAC_', so it fell into the unknown-device-type fallback. Add a new dehumidifier registry, routed via a '_DHM_' modelNum fallback rule, distinct from airconditioner since target humidity (not temperature) is the primary control and there's no climate composite. Reuses airconditioner.py's AUTO_CLEAN/AIR_FILTER/MUTE_ONCE capabilities directly (identical resource shapes on the shared board family). Adds a new humidity sensor + target-humidity number pair and an operating-mode select. Per the adding-device-support skill's "never hard-code the one dump's values" rule, the target-humidity number has no hardcoded min/max (falls back to HA's own 0-100 default for a percentage field) and reads its step live from the device's own `increment` field rather than a spec-sheet-derived constant. The operating-mode select's options come live from supportedModes. /mode/convenient/vs/0 is left unmodeled: only supportedModes is present on this dump, with no live current-value field to confirm a read/write contract. Confirmed against the issue #88 diagnostics dump with zero unbound hrefs. Updates the README's supported-appliance-types table for the new device type. --- README.md | 1 + .../localthings/registry/by_type/__init__.py | 12 +- .../registry/by_type/dehumidifier.py | 23 ++ .../registry/capabilities/dehumidifier.py | 85 ++++ .../localthings/translations/en.json | 9 + .../localthings/translations/nl.json | 9 + tests/fixtures/dehumidifier_device.json | 386 ++++++++++++++++++ tests/fixtures/golden/dehumidifier.json | 17 + tests/test_by_type.py | 14 + tests/test_dehumidifier_capabilities.py | 118 ++++++ tests/test_golden_regression.py | 17 + 11 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/dehumidifier.py create mode 100644 custom_components/localthings/registry/capabilities/dehumidifier.py create mode 100644 tests/fixtures/dehumidifier_device.json create mode 100644 tests/fixtures/golden/dehumidifier.json create mode 100644 tests/test_dehumidifier_capabilities.py diff --git a/README.md b/README.md index c433033..64f7413 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi |---|---| | Air conditioner | `by_type/airconditioner.py` | | Air purifier | `by_type/air_purifier.py` | +| Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | | Cooktop (read-only burner status) | `by_type/cooktop.py` | diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..9de445f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,8 +3,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, - range as _range, range_hood, refrigerator, washer, + air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, + oven, range as _range, range_hood, refrigerator, washer, ) __all__ = [ @@ -19,6 +19,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'airconditioner': airconditioner.REGISTRY, 'air_conditioner': airconditioner.REGISTRY, 'cooktop': cooktop.REGISTRY, + 'dehumidifier': dehumidifier.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, @@ -122,6 +123,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # resource (see airconditioner.py's _AC_IGNORED). if key is None and '-CAWW-' in (model_num or '').upper(): key = 'airconditioner' + # Dehumidifiers (e.g. TP1X_DA_AC_DHM_01001_0000, issue #88) share the + # DA_AC_ board family with the room-AC models above but carry the + # '_DHM_' (DeHuMidifier) token instead of '_RAC_'/'_PRAC_'. Distinct + # registry: target humidity, not temperature, is the primary control, + # and there's no climate composite. + if key is None and '_DHM_' in (model_num or ''): + key = 'dehumidifier' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. if key is None and '_TVTL_' in (model_num or ''): diff --git a/custom_components/localthings/registry/by_type/dehumidifier.py b/custom_components/localthings/registry/by_type/dehumidifier.py new file mode 100644 index 0000000..9c3207e --- /dev/null +++ b/custom_components/localthings/registry/by_type/dehumidifier.py @@ -0,0 +1,23 @@ +"""Dehumidifier device registry (Samsung TP1X_DA_AC_DHM-class, issue #88). + +Shares the DA_AC_ board family with airconditioner.py (power, air filter, +auto-clean, mute-once all use the identical resource shapes), so those three +Capability objects are reused directly rather than duplicated. +""" +from ..capabilities import airconditioner, common, dehumidifier, ignored +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='dehumidifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + dehumidifier.MODE, + dehumidifier.HUMIDITY, + airconditioner.AUTO_CLEAN, + airconditioner.AIR_FILTER, + airconditioner.MUTE_ONCE, + *dehumidifier.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py new file mode 100644 index 0000000..4b58047 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -0,0 +1,85 @@ +"""Capabilities for the Samsung dehumidifier family (TP1X_DA_AC_DHM-class, +issue #88, model AY18CG7500GED). + +Same DA_AC_ board family as the room-AC models in airconditioner.py (shared +power/energy/filter/auto-clean/mute-once resource shapes), but target +humidity -- not temperature -- is this device's primary control, and there +is no climate composite: power, mode, and humidity are exposed as separate +entities rather than folded into one card. +""" +from ..capability import Capability +from ..entities import NumberDesc, SelectDesc, SensorDesc +from .common import int_or_none + + +def _first_mode(rep): + """Representative scalar for the operating-mode select. `modes` is a + single-element list on every dump seen so far, mirroring + airconditioner._first_mode's handling of the same field shape.""" + modes = rep.get('x.com.samsung.da.modes') + if isinstance(modes, (list, tuple)): + return modes[0] if modes else None + return modes + + +MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='operating_mode', rep_fn=_first_mode, + icon='mdi:tune-variant', + options_field='x.com.samsung.da.supportedModes', + write_fn=lambda p, rep, href=None: ( + ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})), + ), +) + +# Target humidity is this device's primary control (the issue-#88 dump's +# equivalent of a thermostat setpoint). No min/max range field is present in +# any dump seen so far -- native_min/native_max are deliberately left unset +# so the number entity falls back to HA's own 0-100 default, the natural +# bound for a percentage field, rather than a bound guessed from one unit's +# spec sheet (see the adding-device-support skill's "never hard-code the one +# dump's values" section). Step comes live from the device's own `increment` +# field. +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.humidity', + device_class='humidity', unit='%', state_class='measurement', + value_fn=int_or_none), + NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity', + device_class='humidity', unit='%', + icon='mdi:water-percent', + entity_category='config', + value_fn=int_or_none, + step_fn=lambda rep: int_or_none(rep.get('increment')) or 1, + write_fn=lambda p, rep, href=None: ( + ['humidity', 'vs', '0'], + {'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})), + ), +) + +# --------------------------------------------------------------------------- +# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state +# or no documented write contract, following the same 'don't guess' rule as +# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in +# the global ignored.IGNORED since some of these hrefs collide with other +# families' schemas. +# --------------------------------------------------------------------------- +_DHM_IGNORED = [ + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM) + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/personality/presence/vs/0', # presence-personalization plumbing (empty item value) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/sensors/vs/0', # empty {} on this dump + '/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert) + # Only supportedModes ([Off, Sleep]) is present -- no live "current + # value" field on this dump to confirm the read/write contract, so per + # the 'don't guess' rule this is left unmodeled rather than assumed. + '/mode/convenient/vs/0', +] + +COVERAGE = [Capability(href=h) for h in _DHM_IGNORED] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..952b319 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -118,6 +118,9 @@ }, "sound_volume": { "name": "Sound volume" + }, + "target_humidity": { + "name": "Target humidity" } }, "select": { @@ -286,6 +289,9 @@ "air_fry": "Air fry" } }, + "operating_mode": { + "name": "Operating mode" + }, "pantry_zone_mode": { "name": "Pantry zone mode", "state": { @@ -523,6 +529,9 @@ "hood_filter_capacity": { "name": "Filter capacity" }, + "humidity": { + "name": "Humidity" + }, "hood_filter_usage": { "name": "Filter usage" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..88a7720 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -118,6 +118,9 @@ }, "sound_volume": { "name": "Geluidsvolume" + }, + "target_humidity": { + "name": "Doelvochtigheid" } }, "select": { @@ -286,6 +289,9 @@ "air_fry": "Airfryen" } }, + "operating_mode": { + "name": "Bedrijfsmodus" + }, "pantry_zone_mode": { "name": "Modus voorraadzone", "state": { @@ -523,6 +529,9 @@ "hood_filter_capacity": { "name": "Filtercapaciteit" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "hood_filter_usage": { "name": "Filterverbruik" }, diff --git a/tests/fixtures/dehumidifier_device.json b/tests/fixtures/dehumidifier_device.json new file mode 100644 index 0000000..1b138e5 --- /dev/null +++ b/tests/fixtures/dehumidifier_device.json @@ -0,0 +1,386 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "224", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "224", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "224", + "300", + "500", + "700" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "100", + "cleaningTime": "30", + "minCleaningTime": "10", + "maxCleaningTime": "60", + "cleaningTimeUnit": "10", + "x.com.samsung.da.supportedStatus": [ + "Start", + "TimedClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "TimedClean", + "Off" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "WaterTankFull_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "259.000000", + "x.com.samsung.da.cumulativePower": "183099", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Smart", + "Max", + "High", + "Medium", + "Quiet", + "ClothesDrying" + ], + "x.com.samsung.da.modes": [ + "Smart" + ], + "x.com.samsung.da.options": [ + "Volume_Mute", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000", + "x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000", + "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": "AY0", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02544A260401", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02538A24032700,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_Heat" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "47", + "x.com.samsung.da.desiredHumidity": "50", + "increment": "5", + "x.com.samsung.da.fivepercentHumidity": "46" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "20001E46070A5A00000000000000", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "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": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "ADH-WW-TP1-23-AY7100", + "versions": [ + "18260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "042110253841FFFFFFFF", + "versions": [ + "24032700", + "FFFFFFFF" + ], + "visVersion": "240327" + }, + { + "type": "Micom", + "modelId": "042110259041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042110255241FFFFFFFF", + "versions": [ + "23020200", + "FFFFFFFF" + ], + "visVersion": "230202" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "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": "/welcome/humidity/vs/0", + "rep": { + "requestId": "0000", + "operatingStatus": "None" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/dehumidifier.json b/tests/fixtures/golden/dehumidifier.json new file mode 100644 index 0000000..cb1a015 --- /dev/null +++ b/tests/fixtures/golden/dehumidifier.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "operating_mode", + "power_switch", + "power_watts", + "target_humidity" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..592cd61 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_dehumidifier_via_dhm_token(self): + """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board + family with the room-AC models but reports no oneUiVersion and + carries the '_DHM_' (DeHuMidifier) token instead of + '_RAC_'/'_PRAC_'/'_WAC_'; falls back to the '_DHM_' token in + modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000', + 'TP1X_DA_AC_DHM_01001_0000', + ) + assert reg is not None + assert reg.name == 'dehumidifier' + 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 diff --git a/tests/test_dehumidifier_capabilities.py b/tests/test_dehumidifier_capabilities.py new file mode 100644 index 0000000..3d8e67f --- /dev/null +++ b/tests/test_dehumidifier_capabilities.py @@ -0,0 +1,118 @@ +"""Tests for Samsung dehumidifier support (issue #88, AY18CG7500GED). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the target-humidity/operating-mode write contracts. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import NumberDesc, SelectDesc + +from tests.conftest import _load_device + + +def _dehumidifier(): + resources = _load_device('dehumidifier') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound(): + reg, resources = _dehumidifier() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_dehumidifier_registry(): + reg, _ = _dehumidifier() + assert reg is not None and reg.name == 'dehumidifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #88 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _dehumidifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('humidity', 'target_humidity', 'operating_mode', 'power_switch', + 'auto_clean', 'air_filter_status', 'mute_once'): + assert key in state, key + + +def test_humidity_sensor_reads_current_value(): + state = _state() + assert state['humidity'] == 47 + + +def test_target_humidity_number_reads_desired_value(): + state = _state() + assert state['target_humidity'] == 50 + + +def test_target_humidity_write_contract(): + desc = _desc('target_humidity') + assert isinstance(desc, NumberDesc) + path, body = desc.write_fn('55', {}) + assert path == ['humidity', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredHumidity': '55'} + + +def test_target_humidity_step_reads_live_increment(): + """Step comes from the device's own `increment` field rather than a + hardcoded constant -- see the adding-device-support skill's 'never + hard-code the one dump's values' section.""" + desc = _desc('target_humidity') + assert desc.step_fn({'increment': '5'}) == 5 + assert desc.step_fn({'increment': '10'}) == 10 + # No live field: falls back to a sane default rather than raising. + assert desc.step_fn({}) == 1 + + +def test_target_humidity_has_no_hardcoded_bounds(): + """No range field is present in any dump seen so far -- native_min/max + are deliberately left unset (falls back to HA's own 0-100 default for a + percentage field) rather than a bound guessed from a spec sheet.""" + desc = _desc('target_humidity') + assert desc.native_min is None + assert desc.native_max is None + assert desc.native_min_fn is None + assert desc.native_max_fn is None + + +def test_operating_mode_select_options_come_from_live_supported_modes(): + """Options are read live from x.com.samsung.da.supportedModes, not a + hardcoded tuple -- so a future device with a different mode set is + handled automatically.""" + desc = _desc('operating_mode') + assert isinstance(desc, SelectDesc) + assert desc.options_field == 'x.com.samsung.da.supportedModes' + assert desc.options == () + + +def test_operating_mode_write_contract(): + desc = _desc('operating_mode') + path, body = desc.write_fn('Quiet', {}) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.modes': ['Quiet']} + + +def test_operating_mode_reads_first_mode(): + state = _state() + assert state['operating_mode'] == 'Smart' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..141f39a 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -85,6 +85,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_dehumidifier(): + """TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_ + board family with the room-AC models but carries the '_DHM_' token; + resolved via the '_DHM_' modelNum fallback in for_device_by_model into a + dedicated dehumidifier registry (target humidity, operating mode, reused + AC filter/auto-clean/mute-once capabilities).""" + from tests.conftest import _load_device + resources = _load_device('dehumidifier') + golden = json.loads((GOLDEN / 'dehumidifier.json').read_text()) + state_keys = _new_state_keys('dehumidifier', 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_cooktop(): from tests.conftest import _load_device resources = _load_device('cooktop') From d53459d047c7cc3e5eef2a589318e105b8f38699 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:40:05 +0000 Subject: [PATCH 6/9] Add device support for Samsung water purifiers (issue #90) The TP2X_WATERPURIFIER_20K water purifier reports no oneUiVersion and its modelNum/description don't match any consumer-prefix or existing board-family token, so it fell into the unknown-device-type fallback with only common capabilities. Add a new water_purifier registry, routed via a 'WATERPURIFIER' modelNum/description fallback rule. Models dispense settings (type/temperature/capacity/pouring status), sterilize and filter status, favorite-capacity presets, and the three water/buzzer locks. Per the adding-device-support skill's "never hard-code the one dump's values" rule: dispense-capacity bounds and step come live from the device's own desiredCapacityRange/ capacityResolution fields (range_field/step_fn), not a hardcoded constant, and the hot-water-temperature control is a select over the live supportedHotTemperatures list rather than a number with invented bounds, since only a few discrete temperatures are selectable. /mode/vs/0 and /automation/waterpurifier/vs/0 are left unmodeled: the former carries an opaque wizard-workflow token with no coherent current-value contract, the latter is a static support-flags blob with no live setting to expose. Confirmed against the issue #90 diagnostics dump with zero unbound hrefs. Updates the README's supported-appliance-types table for the new device type. --- README.md | 1 + .../localthings/registry/by_type/__init__.py | 7 +- .../registry/by_type/water_purifier.py | 18 ++ .../registry/capabilities/water_purifier.py | 165 ++++++++++++ .../localthings/translations/en.json | 48 ++++ .../localthings/translations/nl.json | 48 ++++ tests/fixtures/golden/water_purifier.json | 27 ++ tests/fixtures/water_purifier_device.json | 234 ++++++++++++++++++ tests/test_by_type.py | 12 + tests/test_golden_regression.py | 16 ++ tests/test_water_purifier_capabilities.py | 140 +++++++++++ 11 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 custom_components/localthings/registry/by_type/water_purifier.py create mode 100644 custom_components/localthings/registry/capabilities/water_purifier.py create mode 100644 tests/fixtures/golden/water_purifier.json create mode 100644 tests/fixtures/water_purifier_device.json create mode 100644 tests/test_water_purifier_capabilities.py diff --git a/README.md b/README.md index c433033..ae2dbe7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Dishwasher | `by_type/dishwasher.py` | | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | +| Water purifier | `by_type/water_purifier.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..935d47f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -4,7 +4,7 @@ from typing import Optional from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, - range as _range, range_hood, refrigerator, washer, + range as _range, range_hood, refrigerator, washer, water_purifier, ) __all__ = [ @@ -27,6 +27,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, + 'water_purifier': water_purifier.REGISTRY, } @@ -129,6 +130,10 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' + # Water purifiers (e.g. TP2X_WATERPURIFIER_20K, issue #90) report no + # oneUiVersion and carry the 'WATERPURIFIER' board-family token. + if key is None and 'WATERPURIFIER' in model_identity: + key = 'water_purifier' if key is None and model_identity.startswith('AHD-'): key = 'range_hood' # Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) -- diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py new file mode 100644 index 0000000..e1ac333 --- /dev/null +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -0,0 +1,18 @@ +"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90). +""" +from ..capabilities import common, ignored, water_purifier +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='water_purifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + common.WATER_FILTER, + water_purifier.DISPENSE, + water_purifier.STATUS, + water_purifier.FAVORITE_CAPACITY, + water_purifier.LOCK, + *water_purifier.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py new file mode 100644 index 0000000..f27e484 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -0,0 +1,165 @@ +"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class, +issue #90, model TP2X_WATERPURIFIER_20K). + +Resources verified against the issue #90 diagnostics dump. +""" +from datetime import datetime, timezone + +from ..capability import Capability +from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import int_or_none + + +def _parse_iso_utc(raw): + """Bare ISO datetime with no timezone field alongside it -- treated as + UTC, matching this integration's convention for other bare ISO datetime + fields (see washer.py's drum-clean-log comment).""" + if not raw: + return None + try: + return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) + except ValueError: + return None + + +DISPENSE = Capability( + href='/setting/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType', + icon='mdi:cup-water', + options_field='x.com.samsung.da.supportedTypes', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredType': p})), + # Only a handful of discrete temperatures are selectable (not a + # continuous range) -- a select over the live-reported set, not a + # number with invented bounds. + SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.supportedHotTemperatures', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.tempDesiredHotWater': p})), + # Bounds and step come live from the device's own + # desiredCapacityRange/capacityResolution fields, not a hardcoded + # constant -- see the adding-device-support skill's "never hard-code + # the one dump's values" section. No unit is set: capacityUnit reads + # "C" on this dump, which can't be right for a volume field, so per + # the 'don't guess' rule the unit is left unset rather than assumed + # to be mL. + NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity', + icon='mdi:cup-water', + value_fn=int_or_none, + range_field='x.com.samsung.da.desiredCapacityRange', + step_fn=lambda rep: int_or_none( + rep.get('x.com.samsung.da.capacityResolution')) or 1, + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})), + BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus', + icon='mdi:cup-water', + value_fn=lambda v: v == 'On'), + ), +) + +STATUS = Capability( + href='/status/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status', + icon='mdi:water-pump', + entity_category='diagnostic'), + BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus', + device_class='door', + entity_category='diagnostic', + value_fn=lambda v: v == 'Open'), + SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod', + icon='mdi:calendar-sync', + entity_category='diagnostic'), + SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime', + icon='mdi:timer-outline', + entity_category='diagnostic'), + SensorDesc(key='sterilize_last_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizeLastTime'))), + SensorDesc(key='sterilize_plan_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizePlanTime'))), + SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime', + icon='mdi:timer-sand', + entity_category='diagnostic'), + ), +) + +FAVORITE_CAPACITY = Capability( + href='/favorite/capacity/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity', + icon='mdi:star-outline', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})), + SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity', + icon='mdi:cup-water', + entity_category='config', + options_field='x.com.samsung.da.capacityList', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.defaultCapacity': p})), + ), +) + +LOCK = Capability( + href='/status/lock/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})), + ), +) + +# --------------------------------------------------------------------------- +# Water-purifier-scoped coverage: hrefs with no user-actionable state or no +# confirmed contract, following the 'don't guess' rule. +# --------------------------------------------------------------------------- +_WP_IGNORED = [ + # supportedModes carries a single opaque wizard-workflow token + # ('HOMECARE_WIZARD_V2') and modes reports a completely different, + # unrelated value ('WATERFILTER_DISABLE') not even present in + # supportedModes -- internal plumbing, not a real user-facing mode + # select. OCF-standard /mode/0 mirrors the same vendor resource but is + # already covered by the global ignored.IGNORED (fridge's OCF-native + # vacation-mode flag shares that href). + '/mode/vs/0', + # Static support-flags blob (automation.supported.modes/options) -- no + # live "current automation setting" field to expose. + '/automation/waterpurifier/vs/0', +] + +COVERAGE = [Capability(href=h) for h in _WP_IGNORED] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..54863b6 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -34,6 +34,9 @@ "door_open": { "name": "Door" }, + "filter_door_status": { + "name": "Filter door" + }, "firmware_update": { "name": "Firmware update available" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodic air sensing" }, + "pouring": { + "name": "Pouring" + }, "power_state": { "name": "Power state" }, @@ -110,6 +116,9 @@ "delay_start_hours": { "name": "Delay start" }, + "dispense_capacity": { + "name": "Dispense capacity" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -189,6 +198,9 @@ "8f": "Baby Care" } }, + "dispense_type": { + "name": "Dispense type" + }, "door_alert": { "name": "Door alarm", "state": { @@ -217,6 +229,9 @@ "1f": "Mixed load" } }, + "favorite_capacity": { + "name": "Favorite capacity" + }, "finish_sound": { "name": "Finish sound", "state": { @@ -248,6 +263,9 @@ "extra_high": "Extra high" } }, + "hot_water_temperature": { + "name": "Hot water temperature" + }, "ice_type": { "name": "{instance_name} type", "state": { @@ -499,6 +517,9 @@ "fan_speed_level": { "name": "Fan speed level" }, + "filter_clean_remain_time": { + "name": "Filter clean remaining time" + }, "filter_progress": { "name": "Filter progress" }, @@ -589,12 +610,27 @@ "selfcheck_status": { "name": "Self-check status" }, + "sterilize_last_time": { + "name": "Sterilize last time" + }, + "sterilize_period": { + "name": "Sterilize period" + }, + "sterilize_plan_time": { + "name": "Sterilize plan time" + }, + "sterilize_run_time": { + "name": "Sterilize run time" + }, "super_fine_dust": { "name": "Super fine dust" }, "water_liters": { "name": "Water consumption" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machine state", "state": { @@ -641,6 +677,9 @@ "bubble_soak": { "name": "Bubble soak" }, + "buzz_lock": { + "name": "Buzzer lock" + }, "cabinet_light_dim": { "name": "Brighten gradually" }, @@ -650,6 +689,9 @@ "child_lock": { "name": "Child lock" }, + "coldwater_lock": { + "name": "Cold water lock" + }, "defrost_delay": { "name": "Defrost delay" }, @@ -659,9 +701,15 @@ "fast_preheat": { "name": "Fast preheat" }, + "favorite_capacity_enabled": { + "name": "Favorite capacity" + }, "fridge_sound": { "name": "Sound" }, + "hotwater_lock": { + "name": "Hot water lock" + }, "ice_maker_enabled": { "name": "Ice maker" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..44d1533 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -34,6 +34,9 @@ "door_open": { "name": "Deur" }, + "filter_door_status": { + "name": "Filterdeur" + }, "firmware_update": { "name": "Firmware-update beschikbaar" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodieke luchtmeting" }, + "pouring": { + "name": "Schenken" + }, "power_state": { "name": "Voedingsstatus" }, @@ -110,6 +116,9 @@ "delay_start_hours": { "name": "Uitgestelde start" }, + "dispense_capacity": { + "name": "Schenkhoeveelheid" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -189,6 +198,9 @@ "8f": "Babyverzorging" } }, + "dispense_type": { + "name": "Type dispenser" + }, "door_alert": { "name": "Deuralarm", "state": { @@ -217,6 +229,9 @@ "1f": "Gemengde was" } }, + "favorite_capacity": { + "name": "Favoriete capaciteit" + }, "finish_sound": { "name": "Eindgeluid", "state": { @@ -248,6 +263,9 @@ "extra_high": "Extra hoog" } }, + "hot_water_temperature": { + "name": "Temperatuur warm water" + }, "ice_type": { "name": "Type {instance_name}", "state": { @@ -499,6 +517,9 @@ "fan_speed_level": { "name": "Ventilatorniveau" }, + "filter_clean_remain_time": { + "name": "Resterende tijd filterreiniging" + }, "filter_progress": { "name": "Filterverbruik" }, @@ -589,12 +610,27 @@ "selfcheck_status": { "name": "Status zelfcontrole" }, + "sterilize_last_time": { + "name": "Laatste sterilisatie" + }, + "sterilize_period": { + "name": "Sterilisatie-interval" + }, + "sterilize_plan_time": { + "name": "Geplande sterilisatie" + }, + "sterilize_run_time": { + "name": "Sterilisatieduur" + }, "super_fine_dust": { "name": "Ultrafijnstof" }, "water_liters": { "name": "Waterverbruik" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machinestatus", "state": { @@ -641,6 +677,9 @@ "bubble_soak": { "name": "Bubble Soak" }, + "buzz_lock": { + "name": "Zoemerslot" + }, "cabinet_light_dim": { "name": "Geleidelijk feller" }, @@ -650,6 +689,9 @@ "child_lock": { "name": "Kinderslot" }, + "coldwater_lock": { + "name": "Koudwaterslot" + }, "defrost_delay": { "name": "Ontdooien uitstellen" }, @@ -659,9 +701,15 @@ "fast_preheat": { "name": "Snel voorverwarmen" }, + "favorite_capacity_enabled": { + "name": "Favoriete capaciteit" + }, "fridge_sound": { "name": "Geluid" }, + "hotwater_lock": { + "name": "Heetwaterslot" + }, "ice_maker_enabled": { "name": "IJsmaker" }, diff --git a/tests/fixtures/golden/water_purifier.json b/tests/fixtures/golden/water_purifier.json new file mode 100644 index 0000000..3854bc9 --- /dev/null +++ b/tests/fixtures/golden/water_purifier.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "buzz_lock", + "coldwater_lock", + "dispense_capacity", + "dispense_type", + "favorite_capacity", + "favorite_capacity_enabled", + "filter_clean_remain_time", + "filter_door_status", + "filter_status", + "filter_usage", + "firmware_update", + "hot_water_temperature", + "hotwater_lock", + "pouring", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "sterilize_last_time", + "sterilize_period", + "sterilize_plan_time", + "sterilize_run_time", + "waterpurifier_status" + ] +} diff --git a/tests/fixtures/water_purifier_device.json b/tests/fixtures/water_purifier_device.json new file mode 100644 index 0000000..ecfaed2 --- /dev/null +++ b/tests/fixtures/water_purifier_device.json @@ -0,0 +1,234 @@ +{ + "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": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ], + "x.com.samsung.da.supportedActions": [ + "Start" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000", + "x.com.samsung.da.description": "TP2X_WATERPURIFIER_20K", + "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": "540", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A240615", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24061111, 24091218, 21072807, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-WPWW-TP2-20-COMMON", + "versions": [ + "20240615" + ], + "visVersion": "240615" + }, + { + "type": "Micom", + "modelId": "03800013234100132441", + "versions": [ + "24061111", + "24091218" + ], + "visVersion": "240912" + }, + { + "type": "Micom", + "modelId": "038000132541FFFFFFFF", + "versions": [ + "21072807", + "FFFFFFFF" + ], + "visVersion": "210728" + } + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "28", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.lastResetDate": "2026-04-14T14:33:15", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.hotwaterLock": "Unlocked", + "x.com.samsung.da.coldwaterLock": "Unlocked", + "x.com.samsung.da.buzzLock": "Unlocked" + } + }, + { + "href": "/setting/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.desiredType": "coldwater", + "x.com.samsung.da.tempDesiredHotWater": "85", + "x.com.samsung.da.tempUnit": "C", + "x.com.samsung.da.desiredCapacity": "100", + "x.com.samsung.da.capacityUnit": "C", + "x.com.samsung.da.capacityResolution": "10", + "x.com.samsung.da.desiredCapacityRange": [ + "50", + "2000" + ], + "x.com.samsung.da.supportedTypes": [ + "ambientwater", + "coldwater", + "hotwater" + ], + "x.com.samsung.da.supportedHotTemperatures": [ + "40", + "75", + "85" + ], + "x.com.samsung.da.triggerTime": "1785063153", + "x.com.samsung.da.pourStatus": "Off" + } + }, + { + "href": "/status/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.filterDoorStatus": "Close", + "x.com.samsung.da.sterilizeRunTime": "0", + "x.com.samsung.da.sterilizeStartTime": "2026-07-27T18:00:00", + "x.com.samsung.da.sterilizePeriod": "3", + "x.com.samsung.da.sterilizePlanTime": "2026-07-27T18:00:00", + "x.com.samsung.da.sterilizeLastTime": "2026-07-24T18:09:25", + "x.com.samsung.da.sterilizeMaxTime": "10", + "x.com.samsung.da.filterCleanRemainTime": "0", + "x.com.samsung.da.filterCleanMaxTime": "15" + } + }, + { + "href": "/favorite/capacity/vs/0", + "rep": { + "x.com.samsung.da.switchCapacity": "On", + "x.com.samsung.da.defaultCapacity": "100", + "x.com.samsung.da.maxCapacity": "6", + "x.com.samsung.da.capacityList": [ + "100", + "150", + "200", + "250", + "550", + "9999" + ] + } + }, + { + "href": "/automation/waterpurifier/vs/0", + "rep": { + "automation.supported.modes": [ + "0" + ], + "automation.supported.options": [ + "capacity" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + } + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..b2bf2d1 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,18 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_water_purifier_via_waterpurifier_token(self): + """Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no + oneUiVersion and no consumer-prefix match; falls back to the + 'WATERPURIFIER' token shared by modelNum and description.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000', + 'TP2X_WATERPURIFIER_20K', + ) + assert reg is not None + assert reg.name == 'water_purifier' + 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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..8bd26fe 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -85,6 +85,22 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_water_purifier(): + """TP2X_WATERPURIFIER_20K (issue #90) reports no oneUiVersion; resolved + via the 'WATERPURIFIER' modelNum/description fallback into a dedicated + water_purifier registry (dispense settings, sterilize/filter status, + favorite capacity, and the three lock switches).""" + from tests.conftest import _load_device + resources = _load_device('water_purifier') + golden = json.loads((GOLDEN / 'water_purifier.json').read_text()) + state_keys = _new_state_keys('water_purifier', 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_cooktop(): from tests.conftest import _load_device resources = _load_device('cooktop') diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py new file mode 100644 index 0000000..c0eaa98 --- /dev/null +++ b/tests/test_water_purifier_capabilities.py @@ -0,0 +1,140 @@ +"""Tests for Samsung water-purifier support (issue #90, TP2X_WATERPURIFIER_20K). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the write contracts. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import NumberDesc + +from tests.conftest import _load_device + + +def _water_purifier(): + resources = _load_device('water_purifier') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound(): + reg, resources = _water_purifier() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_water_purifier_registry(): + reg, _ = _water_purifier() + assert reg is not None and reg.name == 'water_purifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #90 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _water_purifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity', + 'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status', + 'hotwater_lock', 'coldwater_lock', 'buzz_lock'): + assert key in state, key + + +def test_dispense_type_options_come_from_live_supported_types(): + desc = _desc('dispense_type') + assert desc.options_field == 'x.com.samsung.da.supportedTypes' + + +def test_dispense_type_write_contract(): + desc = _desc('dispense_type') + path, body = desc.write_fn('hotwater', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredType': 'hotwater'} + + +def test_hot_water_temperature_is_a_select_not_a_number(): + """Only a handful of discrete temperatures are selectable (not a + continuous range) -- confirmed by supportedHotTemperatures being a short + enumerated list, not a [min, max] range field.""" + desc = _desc('hot_water_temperature') + assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures' + + +def test_dispense_capacity_bounds_come_live_not_hardcoded(): + """Bounds and step come from the device's own desiredCapacityRange/ + capacityResolution fields, not a hardcoded constant -- see the + adding-device-support skill's 'never hard-code the one dump's values' + section.""" + desc = _desc('dispense_capacity') + assert isinstance(desc, NumberDesc) + assert desc.native_min is None + assert desc.native_max is None + assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange' + rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'], + 'x.com.samsung.da.capacityResolution': '10'} + assert desc.step_fn(rep) == 10 + + +def test_dispense_capacity_write_contract(): + desc = _desc('dispense_capacity') + path, body = desc.write_fn('550', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredCapacity': '550'} + + +def test_lock_switches_read_unlocked_as_off(): + state = _state() + assert state['hotwater_lock'] is False + assert state['coldwater_lock'] is False + assert state['buzz_lock'] is False + + +def test_lock_switch_write_contracts(): + hot = _desc('hotwater_lock') + cold = _desc('coldwater_lock') + buzz = _desc('buzz_lock') + assert hot.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'}) + assert cold.write_fn('Off', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'}) + assert buzz.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'}) + + +def test_favorite_capacity_options_come_from_live_capacity_list(): + desc = _desc('favorite_capacity') + assert desc.options_field == 'x.com.samsung.da.capacityList' + + +def test_sterilize_timestamps_parsed_as_utc(): + state = _state() + assert state['sterilize_last_time'].tzinfo is not None + assert state['sterilize_plan_time'].tzinfo is not None + + +def test_mode_hrefs_are_ignored_not_guessed(): + """/mode/vs/0's supportedModes carries a single opaque wizard token and + modes reports an unrelated value not even in supportedModes -- internal + plumbing, left unmodeled per the 'don't guess' rule rather than exposed + as a nonsensical select.""" + from custom_components.localthings.registry.capabilities import water_purifier + ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} + assert '/mode/vs/0' in ignored_hrefs + assert '/automation/waterpurifier/vs/0' in ignored_hrefs From 48b34c9e31f3a3e009f7703ff39f8ed1f857e076 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 26 Jul 2026 21:53:06 -0500 Subject: [PATCH 7/9] Delete test_issue_80_cycle_codes_are_labelled Removed test for issue 80 regarding cycle codes. --- tests/test_translations.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_translations.py b/tests/test_translations.py index 0fa9782..0d3c7c3 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,18 +168,4 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() - - -def test_issue_80_cycle_codes_are_labelled(): - """Confirmed by the reporter selecting each cycle on the physical - appliance and reading back the raw code from the entity's state -- - regression-lock those five mappings against accidental removal.""" - select_strings = _load("en")["entity"]["select"] - washer_state = select_strings["washer_cycle_table_02"]["state"] - assert washer_state["52"] == "Eco Cold" - assert washer_state["54"] == "Towels" - assert washer_state["60"] == "Self Clean+" - - dryer_state = select_strings["dryer_cycle_table_03"]["state"] - assert dryer_state["01"] == "Normal" - assert dryer_state["06"] == "Time dry" + From 5e60f9b953a19d6ad2ffe2638952bcf5a2f5c3de Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 03:45:32 +0000 Subject: [PATCH 8/9] Fix stale Window AC golden fixture (current_temperature_c, humidity) PR #75 (WindFree AC) added CURRENT_TEMPERATURE/HUMIDITY capabilities to the shared airconditioner registry, which every AC device picks up -- including the Window AC from PR #87. Both branches built their golden fixtures independently against their own base commit before either landed, so neither saw the other's addition; once both merged, the Window AC's fixture went stale. The two extra keys are real, working sensors from #75's work, not a regression. --- tests/fixtures/golden/airconditioner_window_ac.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index ea27504..5b4b5e4 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -5,10 +5,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "power_watts", "selfcheck_error", From ff6e1ecff4d8ef7cbbf4a1845a6a31f7dea59a9f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 03:56:26 +0000 Subject: [PATCH 9/9] Rename gas cooktop registry's display name to avoid induction_cooktop confusion Renames DeviceRegistry.name from 'cooktop' to 'gas_cooktop' for the NA9300K-class gas-cooktop registry (PR #23), so diagnostics/device-info labels no longer collide with the unrelated induction_cooktop family (issue #86) -- two different OCF surfaces that happen to share the English word "cooktop". Safe rename: _REGISTRY_BY_KEY's 'cooktop' lookup key is unchanged, so all three existing detection paths (oneUiVersion "Cooktop" exact match, the legacy ARTIK051 modelNum rule, and the resource-signature fallback) keep routing real devices exactly as before. Entity unique_ids are built from device serial + entity key, not registry name, so existing entities are unaffected. Only the DeviceInfo.name and diagnostics device_type strings change, both cosmetic. --- README.md | 2 +- .../localthings/registry/by_type/cooktop.py | 14 ++++++++++++-- tests/localthings/test_coordinator.py | 2 +- tests/test_by_type.py | 9 ++++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ae2dbe7..731cfcd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Air purifier | `by_type/air_purifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | -| Cooktop (read-only burner status) | `by_type/cooktop.py` | +| Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | | Dishwasher | `by_type/dishwasher.py` | diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index 08084d9..91fc189 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -1,11 +1,21 @@ -"""Cooktop device registry.""" +"""Gas cooktop device registry (NA9300K-class, PR #23). + +Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label +doesn't collide with the unrelated induction_cooktop family (issue #86, +by_type/induction_cooktop.py) -- two different OCF surfaces that happen to +share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup +key is unchanged: it's relied on by real devices reporting +oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule +(for_device_by_model), and the resource-signature fallback +(for_device_by_resources) alike. +""" from ..capabilities import common, cooktop, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( - name='cooktop', + name='gas_cooktop', capabilities=_build([ *ignored.IGNORED, cooktop.COOKTOP_POWER, diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index a6abccd..cac35f6 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -169,7 +169,7 @@ def test_run_discovery_detects_cooktop_via_resource_signature( coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(_load_device('cooktop')) - assert coordinator.device_type_name == 'cooktop' + assert coordinator.device_type_name == 'gas_cooktop' assert coordinator._unbound_hrefs == [] diff --git a/tests/test_by_type.py b/tests/test_by_type.py index b8216ca..b64ee71 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -42,9 +42,12 @@ class TestForDevice: assert registry.name == 'refrigerator' def test_for_device_returns_cooktop_registry(self): + """The registry's own .name is 'gas_cooktop' (disambiguated from + induction_cooktop), but the lookup key devices route through stays + 'cooktop' -- oneUiVersion "Cooktop" still resolves here.""" registry = for_device('7.0 Cooktop') assert registry is not None - assert registry.name == 'cooktop' + assert registry.name == 'gas_cooktop' def test_for_device_returns_range_hood_registry(self): registry = for_device('7.0 Range Hood') @@ -258,7 +261,7 @@ class TestForDeviceByModel: 'ARTIK051_GLOBAL_COOKTOP', ) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_range_hood_via_ahd_model(self): from custom_components.localthings.registry.by_type import for_device_by_model @@ -319,7 +322,7 @@ class TestForDeviceByResources: reg = for_device_by_resources(_load_device('cooktop')) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_unrelated_mode_options_are_not_cooktop(self): from custom_components.localthings.registry.by_type import for_device_by_resources