From 085e80c92c54636bdb505b57caa7a762427134f1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:39:30 +0000 Subject: [PATCH 01/11] Add regression coverage for WA55A7700AV washer (issue #111) The DA_WM_TP1_21_COMMON board family already routes through the 'WA' consumer-model-prefix fallback and binds cleanly against the washer registry with zero unbound hrefs, but no fixture locked that in for this specific board generation -- add one. --- tests/fixtures/golden/washer_wa55a7700av.json | 30 ++ tests/fixtures/washer_wa55a7700av_device.json | 407 ++++++++++++++++++ tests/test_golden_regression.py | 17 + 3 files changed, 454 insertions(+) create mode 100644 tests/fixtures/golden/washer_wa55a7700av.json create mode 100644 tests/fixtures/washer_wa55a7700av_device.json diff --git a/tests/fixtures/golden/washer_wa55a7700av.json b/tests/fixtures/golden/washer_wa55a7700av.json new file mode 100644 index 0000000..930f097 --- /dev/null +++ b/tests/fixtures/golden/washer_wa55a7700av.json @@ -0,0 +1,30 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "detergent_low", + "detergent_quantity", + "detergent_water_hardness", + "diagnosis_status", + "drum_clean_cycles_remaining", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "softener_concentration", + "softener_low", + "softener_quantity", + "spin_speed", + "wash_temperature" + ] +} \ No newline at end of file diff --git a/tests/fixtures/washer_wa55a7700av_device.json b/tests/fixtures/washer_wa55a7700av_device.json new file mode 100644 index 0000000..d52657b --- /dev/null +++ b/tests/fixtures/washer_wa55a7700av_device.json @@ -0,0 +1,407 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "40500", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784988000", + "x.com.samsung.da.cumulativeDateUTC": "1785013200" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0145", + "UpdateAllow_NotAllowed", + "Course_01", + "LaundryOutTime_0", + "AddGarmentIndicator_Off", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_18", + "DrumCleanProposal_20", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_6", + "AvailableDelayTime_67", + "DetergentLevelCtrl_0", + "SoftenerLevelCtrl_3", + "SupportedDetergentLevelCtrl_00010203", + "SupportedSoftenerLevelCtrl_00010203", + "DetergentLevel2Ctrl_1", + "SoftenerLevel2Ctrl_1", + "SupportedDetergentLevel2Ctrl_010203", + "SupportedSoftenerLevel2Ctrl_010203", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_4205DC8208E8A206AE", + "SuperSpeed_Off", + "SendToDevice_Off", + "DetergentCycleUsed_0", + "SoftenerCycleUsed_0", + "CloudCourse_001C590549164D114A224C2037F0AC22", + "CloudExtraCourse_5958", + "OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00", + "EnergyLevelSet_05020403040204040401030203", + "MostUsed_01843E913FA43BC33E", + "SuperSpeedSet_F0F000F00000000000000000", + "DispenserSet_22222222222222FFF2000022", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2025-09-07T18:15:52|2025-10-05T18:49:21|2025-11-23T18:20:07|2026-01-01T19:11:59|2026-02-15T03:36:21|2026-03-09T19:59:19|2026-04-19T21:35:33|2026-05-10T19:21:00|2026-06-12T21:32:16|2026-06-29T06:29:50", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "401843E913FA43BC33E53853E913FA43FC53E86843E913FA43FC33E7C853E923FA43FC53E65821E913FA43FC33E7D843E923FA31FC33E1783089102A410C3087E85209204A308C0005E8000913FA43FC00055831E913FA31FC20E57821E913FA20FC10204843E913FA43FC13E" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:07:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:07:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20233742|20000001001511200AAB020200000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WA7700A/DC92-03005B_004A", + "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": "210", + "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|20233742|20000001001511200AAB020200000000", + "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_20233742210931500FFFFF203005422212139101FFFF(01452023374220300542_30221213)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02337B21093150,03005B22121391", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2023394522081051012FFFFFFFFFFFFFFFFFFFFFFFFE(014520233945FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02339E22081051,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-07:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.detergentLevel": "1", + "x.com.samsung.da.softenerLevel": "2", + "x.com.samsung.da.waterTemperature": "Warm", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "Cool", + "EcoWarm", + "Warm", + "Hot" + ], + "x.com.samsung.da.spinLevel": "High", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "Low", + "Medium", + "High", + "ExtraHigh" + ], + "x.com.samsung.da.soilLevel": "Normal", + "x.com.samsung.da.supportedSoilLevel": [ + "None", + "ExtraLight", + "Light", + "Normal", + "Heavy", + "ExtraHeavy" + ], + "x.com.samsung.da.rinseCycles": "1", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_01", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(WA55A7700AV/US)" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "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": "01452023374220300542", + "versions": [ + "21093150", + "22121391" + ], + "visVersion": "221213" + }, + { + "type": "Micom", + "modelId": "014520233945FFFFFFFF", + "versions": [ + "22081051", + "FFFFFFFF" + ], + "visVersion": "220810" + } + ] + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "US" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Phoenix", + "offset": "-07:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 30cf536..c72e82d 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -493,6 +493,23 @@ def test_registry_reproduces_golden_state_keys_for_oven(): ) +def test_registry_reproduces_golden_state_keys_for_washer_wa55a7700av(): + """DA_WM_TP1_21_COMMON top-load washer (model WA55A7700AV, issue #111) + -- a different board generation than the WA8000T's TP2_20_COMMON, + reached through the same 'WA' consumer-model-prefix fallback (issue + #106). Binds cleanly against the existing washer registry with zero + unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('washer_wa55a7700av') + golden = json.loads((GOLDEN / 'washer_wa55a7700av.json').read_text()) + state_keys = _new_state_keys('washer_wa55a7700av', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 46dd91ce32c6a2960e6b012ab5f5b89a960a0782 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:39:51 +0000 Subject: [PATCH 02/11] Add regression coverage for NE8300D range (issue #112) TP1X_DA-KS-RANGE-0102X already resolves via the '-RANGE-' modelNum fallback and /cooktopmonitoring/vs/0 already binds through range.COOKTOP_MONITORING, so this model binds with zero unbound hrefs today -- add a fixture to lock that in. --- tests/fixtures/golden/range_ne8300d.json | 27 ++ tests/fixtures/range_ne8300d_device.json | 322 +++++++++++++++++++++++ tests/test_golden_regression.py | 17 ++ 3 files changed, 366 insertions(+) create mode 100644 tests/fixtures/golden/range_ne8300d.json create mode 100644 tests/fixtures/range_ne8300d_device.json diff --git a/tests/fixtures/golden/range_ne8300d.json b/tests/fixtures/golden/range_ne8300d.json new file mode 100644 index 0000000..49f4d08 --- /dev/null +++ b/tests/fixtures/golden/range_ne8300d.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "current_temp_c", + "cycle_active", + "door_open", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound", + "warming_center_state" + ] +} \ No newline at end of file diff --git a/tests/fixtures/range_ne8300d_device.json b/tests/fixtures/range_ne8300d_device.json new file mode 100644 index 0000000..75e7e61 --- /dev/null +++ b/tests/fixtures/range_ne8300d_device.json @@ -0,0 +1,322 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-23-OVEN-3", + "versions": [ + "40241114" + ], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "071240473041FFFFFFFF", + "versions": [ + "23123000", + "FFFFFFFF" + ], + "visVersion": "231230" + }, + { + "type": "Micom", + "modelId": "071260141341FFFFFFFF", + "versions": [ + "24010300", + "FFFFFFFF" + ], + "visVersion": "240103" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0102X|40473041|5001011E031811010A00000000000000", + "x.com.samsung.da.description": "NE8300D-/AA0", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "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": "24111400", + "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": "04730A23123000", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "01413A24010300", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "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": "KR2", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "ConvectionBake", + "ConvectionRoast", + "Bake", + "Broil", + "AirFryer", + "KeepWarm", + "BreadProof", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE8300D-/AA0", + "SettingPossible_0", + "TimeAutoSync_On", + "UpperLamp_Off", + "TimeSystem_12", + "Sound_On", + "AdjustingTemp_147", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlter_On" + ], + "x.com.samsung.da.defaultMode": "Bake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"ConvectionBake\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"1\",\"tempIntervalF\":\"1\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"1\",\"tempIntervalF\":\"1\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Bake\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"1\",\"tempIntervalF\":\"1\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Broil\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61442\",\"tempMaxC\":\"61441\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61442\",\"tempMaxF\":\"61441\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"AirFryer\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"1\",\"tempIntervalF\":\"1\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"KeepWarm\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"BreadProof\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"Dehydrate\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"105\",\"tempDefaultC\":\"65\",\"tempListLengthC\":\"0\",\"tempMinF\":\"100\",\"tempMaxF\":\"225\",\"tempDefaultF\":\"150\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"1\",\"tempIntervalF\":\"1\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SelfClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SteamClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "causeSource.state": "SETB_Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-27T04:46:16" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "0", + "x.com.samsung.da.current": "175", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Fahrenheit" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Phoenix", + "offset": "-07:00", + "DST": "OFF" + } + }, + { + "href": "/oven/spec/vs/0", + "rep": { + "cavityInfo": { + "numberOfCavity": 1, + "type": "single", + "serviceMainCavity": "single", + "cavityList": [ + { + "name": "single", + "flexSupport": false, + "single": { + "resourceId": 0, + "supportedFeatureList": [ + "" + ] + } + } + ] + } + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.supportedCooktopRunningState": [ + "Run", + "Ready" + ], + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopRunningState": "Ready" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c72e82d..7eeb915 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -510,6 +510,23 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa55a7700av(): ) +def test_registry_reproduces_golden_state_keys_for_range_ne8300d(): + """TP1X_DA-KS-RANGE-0102X (model NE8300D, issue #112) -- reports no + oneUiVersion; resolved via the '-RANGE-' modelNum token fallback. + Binds cleanly against the existing range registry, including + /cooktopmonitoring/vs/0 via range.COOKTOP_MONITORING, with zero + unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('range_ne8300d') + golden = json.loads((GOLDEN / 'range_ne8300d.json').read_text()) + state_keys = _new_state_keys('range_ne8300d', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From f62b4aeec8cb051557df634224f67acd2cdf23cb Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:40:12 +0000 Subject: [PATCH 03/11] Detect ARA-WW-TP1-22-COMMON wall-mount RACs as airconditioner (issues #115, #116, #117, #120) AR10/13/18BYEAAWKNME report no oneUiVersion and no '_RAC_'/'-RAC-' token at all, so for_device_by_model() fell through to 'unknown' and every href went uncovered. The board carries the same TP1X-class resource surface as every other room AC already supported (mode/ convenient/wind/temperature/power/filter/humidity), so this reuses the existing airconditioner registry via a new 'ARA-WW-' modelNum fallback rather than adding a new device type -- confirmed against all four reporters' dumps binding cleanly with zero unbound hrefs. --- .../localthings/registry/by_type/__init__.py | 10 + .../airconditioner_ara_ww_tp1_22_device.json | 476 ++++++++++++++++++ .../golden/airconditioner_ara_ww_tp1_22.json | 17 + tests/test_by_type.py | 15 + tests/test_golden_regression.py | 18 + 5 files changed, 536 insertions(+) create mode 100644 tests/fixtures/airconditioner_ara_ww_tp1_22_device.json create mode 100644 tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 712dace..8590dd7 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -198,6 +198,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # here) -- no WAC-specific resources needed. if key is None and '_WAC_' in (model_num or ''): key = 'airconditioner' + # ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/ + # #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at + # all -- the board family is spelled 'ARA-WW-' instead. Same resource + # surface as the other TP1X-class room ACs above (mode/convenient/wind/ + # temperature/power/filter/humidity all confirmed against these dumps + # binding cleanly against the existing airconditioner registry once + # routed here) -- no ARA-WW-specific resources needed, so this reuses + # the same registry rather than adding a new device type. + if key is None and 'ARA-WW-' in (model_num or '').upper(): + 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_ara_ww_tp1_22_device.json b/tests/fixtures/airconditioner_ara_ww_tp1_22_device.json new file mode 100644 index 0000000..d396db9 --- /dev/null +++ b/tests/fixtures/airconditioner_ara_ww_tp1_22_device.json @@ -0,0 +1,476 @@ +{ + "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": "54", + "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": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "Nano", + "NanoSleep" + ] + } + }, + { + "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": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Left_And_Right", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "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-27T06:07:25", + "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-27T06:07:25", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "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": "26.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": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 26.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0.000000", + "x.com.samsung.da.cumulativePower": "3193591", + "x.com.samsung.da.cumulativeSavedPower": "158331", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Dry" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "OutdoorConnection_Connected", + "OutdoorTemp_88", + "CoolCapa_53", + "WarmCapa_0", + "Light_Off", + "Volume_100", + "StopAutoClean_Idle", + "Autoclean_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "WashAlarm_Off", + "OptionCode_56440", + "ExtendOptionCode_246413", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_On" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "x.com.samsung.da.displaycondition": "Enable" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARA-WW-TP1-22-COMMON|10229641|60010535001511014600083200800000", + "x.com.samsung.da.description": "ARA-WW-TP1-22-COMMON", + "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": "010", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "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": "02378A260327", + "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": "102296A23012700", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "102295A22113000,102299A10000300", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+08: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_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "64" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 1, + "start": "2026-07-27T05:07:32Z", + "duration": 20, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.duration": "20:47:00", + "x.com.samsung.da.drlcStartTime": "2026-07-27T05:07:32Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C016102490C000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "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": "ARA-WW-TP1-22-COMMON", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + } + ] + } + }, + { + "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": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Kuala_Lumpur", + "offset": "+08:00", + "DST": "OFF" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "dreamer" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json new file mode 100644 index 0000000..a2b459c --- /dev/null +++ b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts" + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index e135584..a291b10 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -314,6 +314,21 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_airconditioner_via_ara_ww_token(self): + """Issues #115/#116/#117/#120: ARA-WW-TP1-22-COMMON wall-mount RACs + (AR10/13/18BYEAAWKNME) report no oneUiVersion and no + '_RAC_'/'-RAC-'/'_PRAC_' token at all -- falls back to the + 'ARA-WW-' token in modelNum, reusing the same airconditioner + registry as every other TP1X-class room AC rather than a new + device type.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'ARA-WW-TP1-22-COMMON|10229641|6001051A001511014600083200800000', + 'ARA-WW-TP1-22-COMMON', + ) + 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 7eeb915..9cebe46 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -527,6 +527,24 @@ def test_registry_reproduces_golden_state_keys_for_range_ne8300d(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_ara_ww_tp1_22(): + """ARA-WW-TP1-22-COMMON wall-mount RACs (model AR10/13/18BYEAAWKNME, + issues #115/#116/#117/#120) report no oneUiVersion and no + '_RAC_'/'-RAC-' token -- resolved via the 'ARA-WW-' modelNum fallback. + Same resource surface as the other TP1X-class room ACs; binds cleanly + against the existing airconditioner registry with zero unbound hrefs, + so no new device type was needed.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_ara_ww_tp1_22') + golden = json.loads((GOLDEN / 'airconditioner_ara_ww_tp1_22.json').read_text()) + state_keys = _new_state_keys('airconditioner_ara_ww_tp1_22', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 2c8a765036a0530132ac71340ea5f019d8e34646 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:40:32 +0000 Subject: [PATCH 04/11] Downgrade a lone poll-failure reconnect to info (issue #119) Samsung's firmware occasionally drops the DTLS session briefly -- normal appliance-side behavior per the README's "Known device behavior" section -- so the coordinator recovering from that on its own doesn't need a WARNING. Only escalate once reconnects pile up within a trailing 60s window (5+), matching the README's own "more than a handful per minute" definition of an actually broken connection. --- custom_components/localthings/coordinator.py | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index dc9e960..b483efe 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -85,6 +85,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): _OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S _RECONNECT_PAUSE_S: float = 5.0 + # A single reconnect is normal appliance-side behavior (see the + # README's "Known device behavior" section) -- Samsung's firmware + # drops the DTLS session briefly every now and then, and the + # coordinator recovering from that on its own isn't something a user + # needs to see at WARNING. Only escalate once reconnects pile up + # within a short window, matching the README's own "more than a + # handful per minute" definition of an actually-broken connection + # (issue #119). + _RECONNECT_WARN_WINDOW_S: float = 60.0 + _RECONNECT_WARN_THRESHOLD: int = 5 + # A block-level ACK timeout on the summary GET doesn't prove the # session is dead (see _poll_once) — require this many in a row # before treating it as one. A single slow transfer on an otherwise @@ -136,6 +147,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.one_ui_version: str = '' self._consecutive_poll_timeouts = 0 self._unbound_hrefs: list[str] = [] + self._reconnect_times: list[float] = [] # ------------------------------------------------------------------ # Session management (all blocking — must run in executor) @@ -444,6 +456,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._consecutive_poll_timeouts += 1 return self._consecutive_poll_timeouts < self._POLL_TIMEOUT_LIMIT + def _reconnect_is_frequent(self) -> bool: + """True once this cycle's reconnect is the Nth within the trailing + warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD.""" + now = time.monotonic() + self._reconnect_times = [ + t for t in self._reconnect_times + if now - t < self._RECONNECT_WARN_WINDOW_S + ] + self._reconnect_times.append(now) + return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD + # ------------------------------------------------------------------ # DataUpdateCoordinator hook # ------------------------------------------------------------------ @@ -470,7 +493,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._consecutive_poll_timeouts = 0 # One reconnect attempt — pause briefly so the device can # clean up its DTLS session state before we knock again. - self._log.warning("poll failed, reconnecting: %s", e) + # A lone reconnect is routine (see the README's "Known + # device behavior" section); only warn once they're piling + # up within the trailing window. + if self._reconnect_is_frequent(): + self._log.warning("poll failed, reconnecting: %s", e) + else: + self._log.info("poll failed, reconnecting: %s", e) await self.hass.async_add_executor_job(self._close_session) await asyncio.sleep(self._RECONNECT_PAUSE_S) try: From 6f10a3b7960600f09385cadc5a379c3c2677de59 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:41:04 +0000 Subject: [PATCH 05/11] Support 2-axis wind oscillation and overload-protection status on newer WindFree boards (issue #126) Newer TP1X_DA-AC-RAC-01011_0000 firmware (Bespoke AI WindFree Deluxe, AR60H10D1JWNME) drops /wind/direction/vs/0 entirely and reports swing via a separate vertical/horizontal Swing|Fix pair on /wind/oscillation/vs/0 instead, which left swing_mode/swing_modes silently empty and the href unbound. climate.py now falls back to the oscillation resource when /wind/direction/vs/0 is absent, mapping the same off/vertical/horizontal/both vocabulary the existing swing control already uses. Also binds the board's /anomalyload/vs/0 overload-response resource as read-only diagnostic sensors (operation state + mode) -- the same "don't guess" precedent as the existing CURRENT_LIMIT capability, since nothing in the dump confirms the exact behavioral difference between its 'Alarm' and 'PowerSaving' modes or whether toggling it is safe on live HVAC hardware. Most of the other gaps this issue reported (Fan-only mode, WindFree preset labels, target-temperature channel selection, power on/off via the vendor resource) turned out to already be fixed by the just-merged cool-only global RAC work. --- custom_components/localthings/climate.py | 44 +- .../registry/by_type/airconditioner.py | 1 + .../registry/capabilities/airconditioner.py | 42 +- .../localthings/translations/en.json | 10 + .../localthings/translations/nl.json | 10 + ...nditioner_windfree_oscillation_device.json | 590 ++++++++++++++++++ .../airconditioner_windfree_oscillation.json | 21 + .../test_climate_wind_oscillation_fallback.py | 33 + tests/test_golden_regression.py | 23 + 9 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/airconditioner_windfree_oscillation_device.json create mode 100644 tests/fixtures/golden/airconditioner_windfree_oscillation.json create mode 100644 tests/test_climate_wind_oscillation_fallback.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 063b811..89a7e8b 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -48,6 +48,7 @@ from .registry.capabilities.airconditioner import ( HREF_TEMPS_VS as TEMPS_VS_HREF, HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, + HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, ) from .registry.capabilities.common import normalize_temp_unit @@ -120,6 +121,26 @@ _DEVICE_TO_SWING: dict[str, str] = { } _SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} +# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without +# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles +# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING +# above (off/vertical/horizontal/both), just read from/written to a pair +# of fields rather than a single one. +def _oscillation_swing(rep: dict) -> str | None: + vertical = rep.get('vertical') + horizontal = rep.get('horizontal') + if vertical is None and horizontal is None: + return None + v = vertical == 'Swing' + h = horizontal == 'Swing' + if v and h: + return 'both' + if v: + return 'vertical' + if h: + return 'horizontal' + return 'off' + # Preset (convenient mode): resolved dynamically from the device's own # /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' # code maps to HA's PRESET_NONE ("no preset active"); every other code is @@ -358,13 +379,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def fan_modes(self) -> list[str]: return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + def _swing_via_direction(self) -> bool: + """True when WIND_DIRECTION_HREF is the swing channel to use -- + signalled by its presence. Boards without it (issue #126) report + the 2-axis oscillation resource instead; see _oscillation_swing.""" + return bool(self._rep(WIND_DIRECTION_HREF)) + @property def swing_mode(self): - return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) + if self._swing_via_direction(): + return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) + return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF)) @property def swing_modes(self) -> list[str]: - return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) + if self._swing_via_direction(): + return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) + if self._rep(WIND_OSCILLATION_HREF): + return list(_SWING_TO_DEVICE.keys()) + return [] @property def preset_mode(self): @@ -428,7 +461,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: - await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) + if self._swing_via_direction(): + await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) + return + if self._rep(WIND_OSCILLATION_HREF): + await self.coordinator.async_send_command( + self._bound, ('oscillation', swing_mode)) async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_mode == PRESET_AI_COMFORT: diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7a7c38c..9ea273a 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry( airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, + airconditioner.ANOMALY_LOAD, airconditioner.CURRENT_TEMPERATURE, airconditioner.CURRENT_TEMPERATURE_VS, airconditioner.HUMIDITY, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 67bec22..c8e9576 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -35,13 +35,19 @@ HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write ta HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode +# Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no +# HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource +# (separate vertical/horizontal Swing|Fix toggles, each with its own angle). +# climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same +# duality pattern as the OCF/vendor temperature channel below. +HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, - HREF_CONVENIENT, + HREF_WIND_OSCILLATION, HREF_CONVENIENT, ] @@ -177,6 +183,15 @@ def _climate_write(payload, rep, href=None): return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}) if kind == 'swing': return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value}) + if kind == 'oscillation': + # value is the HA swing_mode string ('off'/'vertical'/'horizontal'/ + # 'both'); both axes are independent Swing|Fix toggles on this + # resource, so one HA value maps to a pair of fields written + # together (see climate.py's oscillation fallback). + return (['wind', 'oscillation', 'vs', '0'], { + 'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix', + 'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix', + }) if kind == 'preset': return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value}) return None @@ -299,6 +314,31 @@ CURRENT_LIMIT = Capability( ), ) +# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree +# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs +# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration +# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload +# alarm vs. automatic power throttling), but nothing in the dump confirms +# the exact behavioral difference between the two modes or whether writing +# 'operation' is safe on live HVAC hardware -- exposed read-only per the +# 'don't guess' rule, same precedent as CURRENT_LIMIT above. +ANOMALY_LOAD = Capability( + href='/anomalyload/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='overload_protection_active', field='operation', + icon='mdi:flash-alert', + entity_category='diagnostic', + value_fn=lambda v: v == 'On'), + SensorDesc(key='overload_protection_mode', field='mode', + device_class='enum', + options=('alarm', 'powersaving'), + translation_key='overload_protection_mode', + icon='mdi:flash-alert', entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + ), +) + # The climate entity already surfaces current_temperature as a card # attribute, but that's not enough for history graphs/automations/ # statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 1786d98..14c0a01 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -25,6 +25,9 @@ "current_limit_enabled": { "name": "Current limit enabled" }, + "overload_protection_active": { + "name": "Overload protection active" + }, "cycle_active": { "name": "Running" }, @@ -515,6 +518,13 @@ "current_limit_level": { "name": "Current limit level" }, + "overload_protection_mode": { + "name": "Overload protection mode", + "state": { + "alarm": "Alarm", + "powersaving": "Power saving" + } + }, "current_temp_c": { "name": "Temperature" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index a6e73ed..454bb86 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -25,6 +25,9 @@ "current_limit_enabled": { "name": "Stroombegrenzing ingeschakeld" }, + "overload_protection_active": { + "name": "Overbelastingsbeveiliging actief" + }, "cycle_active": { "name": "Actief" }, @@ -515,6 +518,13 @@ "current_limit_level": { "name": "Niveau stroombegrenzing" }, + "overload_protection_mode": { + "name": "Modus overbelastingsbeveiliging", + "state": { + "alarm": "Alarm", + "powersaving": "Energiebesparing" + } + }, "current_temp_c": { "name": "Temperatuur" }, diff --git a/tests/fixtures/airconditioner_windfree_oscillation_device.json b/tests/fixtures/airconditioner_windfree_oscillation_device.json new file mode 100644 index 0000000..3bc13ee --- /dev/null +++ b/tests/fixtures/airconditioner_windfree_oscillation_device.json @@ -0,0 +1,590 @@ +{ + "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.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-27T11:59:06", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/anomalyload/vs/0", + "rep": { + "operation": "Off", + "mode": "Alarm", + "supportedModes": [ + "Alarm", + "PowerSaving" + ], + "savingTime": "20", + "supportedSavingTime": [ + "20", + "40", + "60" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C01610CC90C0F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_COOLONLY_4.0", + "AI_3.0", + "Auto_To_AI", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ], + "InstalledRoomSize": "0.0", + "SkipInstalledRoomSize": "false", + "RoomSizeMenuNumber": "Manual" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1785153515", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativePower": "47536", + "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": "+08:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "180", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "44", + "x.com.samsung.da.filterDesiredUsage": "180", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "46" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10281241|6001051A001911018E0049300002A900", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "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": "02825A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02812A25111800,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02813A25101500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR2", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "Nano", + "NanoSleep", + "DryComfort" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.options": [ + "OptionCode_56416", + "Sleep_0", + "SleepRemainTime_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "Volume_100", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "InDoorVentil_Off", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "ExtendOptionCode_246413", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "Off", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off", + "rt": [ + "x.com.samsung.da.option.muteonce" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "2", + "displayCondition": "Enable" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8EFFFFFFFF101E121EFFFFFFFFFFFF000001001F0001000000000000009C00FFFF1E00", + "x.com.samsung.da.id": "RAC", + "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": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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.current": "29.0", + "x.com.samsung.da.desired": "18.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/wind/oscillation/vs/0", + "rep": { + "vertical": "Fix", + "supportedVertical": [ + "Swing", + "Fix" + ], + "verticalAngle": "4", + "supportedVerticalAngle": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "horizontal": "Fix", + "supportedHorizontal": [ + "Swing", + "Fix" + ], + "horizontalSwingMode": "AllSwing", + "supportedHorizontalSwingMode": [ + "AllSwing", + "LeftSwing", + "RightSwing" + ], + "horizontalAngle": "3", + "supportedHorizontalAngle": [ + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-WW-TP1-26-AR6X00MG", + "versions": [ + "11260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "845241131741FFFFFFFF", + "versions": [ + "25102900", + "FFFFFFFF" + ], + "visVersion": "251029" + }, + { + "type": "Micom", + "modelId": "045210281241FFFFFFFF", + "versions": [ + "25111800", + "FFFFFFFF" + ], + "visVersion": "251118" + }, + { + "type": "Micom", + "modelId": "045210281341FFFFFFFF", + "versions": [ + "25101500", + "FFFFFFFF" + ], + "visVersion": "251015" + }, + { + "type": "Micom", + "modelId": "0452FFFFFFFFFFFFFFFF", + "versions": [ + "FFFFFFFF", + "FFFFFFFF" + ], + "visVersion": "999999" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Kuala_Lumpur", + "offset": "+08:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "K9 Unit" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "temperature": 25 + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_windfree_oscillation.json b/tests/fixtures/golden/airconditioner_windfree_oscillation.json new file mode 100644 index 0000000..000bed7 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_windfree_oscillation.json @@ -0,0 +1,21 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "overload_protection_active", + "overload_protection_mode", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} \ No newline at end of file diff --git a/tests/test_climate_wind_oscillation_fallback.py b/tests/test_climate_wind_oscillation_fallback.py new file mode 100644 index 0000000..d9f5292 --- /dev/null +++ b/tests/test_climate_wind_oscillation_fallback.py @@ -0,0 +1,33 @@ +"""Tests for the /wind/oscillation/vs/0 swing fallback helper in climate.py +(issue #126). + +`_oscillation_swing` is a pure module-level function, like `_temps_vs_item` +in test_climate_temperature_fallback.py -- testable directly without a +coordinator/entity. This covers the fallback-selection logic that the +registry-level "no unbound hrefs" test in test_golden_regression.py doesn't +reach: newer WindFree firmware reports no /wind/direction/vs/0 at all, so +swing_mode/swing_modes would silently be None/empty without this fallback. +""" +from custom_components.localthings.climate import _oscillation_swing + + +def test_oscillation_swing_off_when_both_fixed(): + assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Fix'}) == 'off' + + +def test_oscillation_swing_vertical_only(): + assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Fix'}) == 'vertical' + + +def test_oscillation_swing_horizontal_only(): + assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Swing'}) == 'horizontal' + + +def test_oscillation_swing_both(): + assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Swing'}) == 'both' + + +def test_oscillation_swing_none_when_resource_absent(): + """Boards with /wind/direction/vs/0 instead don't populate this + resource at all -- callers must fall through cleanly, not raise.""" + assert _oscillation_swing({}) is None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 9cebe46..f56a9c4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -545,6 +545,29 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_ara_ww_tp1_22( ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscillation(): + """TP1X_DA-AC-RAC-01011_0000 Bespoke AI WindFree Deluxe (model + AR60H10D1JWNME, issue #126) -- the same board family as the + airconditioner_tp1x_da_ac_rac_01011 fixture, but a newer firmware + variant reporting no /wind/direction/vs/0 at all: swing lives on the + 2-axis /wind/oscillation/vs/0 resource instead (airconditioner. + HREF_WIND_OSCILLATION, climate.py's oscillation fallback), and it adds + an /anomalyload/vs/0 overload-response resource (airconditioner. + ANOMALY_LOAD, read-only per the 'don't guess' rule). Binds cleanly + with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_windfree_oscillation') + golden = json.loads( + (GOLDEN / 'airconditioner_windfree_oscillation.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_windfree_oscillation', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From e44fef7085ee43888993e7a5e8f7163132c69b7e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 13:41:36 +0000 Subject: [PATCH 06/11] Route combi microwaves onto the existing oven registry (issue #121) TP1X_DA-KS-MICROWAVE-01041 (MW7300B) reports no oneUiVersion and an unrecognized consumer token, so it fell back to 'unknown' and only got common capabilities. It shares the same '/oven/vs/0' cavity resource and '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*) as the wall oven already supported, so this reuses that registry via a new '-MICROWAVE-' modelNum fallback rather than adding a new device type. The only href it didn't already cover was /recipe/cook/vs/0, an empty quick-recipe-display blob bound with no entity per the 'don't guess' rule. --- .../localthings/registry/by_type/__init__.py | 9 + .../localthings/registry/by_type/oven.py | 1 + .../localthings/registry/capabilities/oven.py | 6 + tests/fixtures/golden/oven_mw7300b.json | 25 ++ tests/fixtures/oven_mw7300b_device.json | 278 ++++++++++++++++++ tests/test_by_type.py | 14 + tests/test_golden_regression.py | 20 ++ 7 files changed, 353 insertions(+) create mode 100644 tests/fixtures/golden/oven_mw7300b.json create mode 100644 tests/fixtures/oven_mw7300b_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 8590dd7..9039566 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -231,6 +231,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. if key is None and '-OVEN-' in (model_num or '').upper(): key = 'oven' + # Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same + # board family as the wall oven above (an '/oven/vs/0' cavity resource + # with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a + # different cavity. Reports no oneUiVersion and doesn't match the + # washer/dryer/dishwasher prefix map either. Binds cleanly against the + # existing oven registry once routed here (confirmed against the issue + # #121 dump) -- no microwave-specific device type needed. + if key is None and '-MICROWAVE-' in (model_num or '').upper(): + key = 'oven' # Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue # #86) -- same board family and '/cooktop/status/vs/0' resource shape # as the range combo above, but no oven attached at all. Distinct from diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index c4028ce..252f1dc 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -15,5 +15,6 @@ REGISTRY = DeviceRegistry( oven.OVEN_DOOR, oven.OVEN_CONNECTED, oven.OVEN_SPEC, + oven.OVEN_RECIPE_COOK, ]), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index c679fac..4b47a38 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -338,6 +338,12 @@ OVEN_CONNECTED = Capability( # fields worth exposing. OVEN_SPEC = Capability(href='/oven/spec/vs/0') +# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded +# string (language/menu/servingSize/option) with every field blank on the +# only dump seen, and no documented write contract. No entity to bind per +# the 'don't guess' rule; a bare Capability still marks the href covered. +OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0') + OVEN_MODE = Capability( href='/mode/vs/0', poll_tier='warm', diff --git a/tests/fixtures/golden/oven_mw7300b.json b/tests/fixtures/golden/oven_mw7300b.json new file mode 100644 index 0000000..435dec4 --- /dev/null +++ b/tests/fixtures/golden/oven_mw7300b.json @@ -0,0 +1,25 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "current_temp_c", + "cycle_active", + "door_open", + "energy_kwh", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "progress_percentage", + "remote_control", + "sound" + ] +} \ No newline at end of file diff --git a/tests/fixtures/oven_mw7300b_device.json b/tests/fixtures/oven_mw7300b_device.json new file mode 100644 index 0000000..f5a4ded --- /dev/null +++ b/tests/fixtures/oven_mw7300b_device.json @@ -0,0 +1,278 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-22-OVEN-2", + "versions": [ + "40241114" + ], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "074340475341FFFFFFFF", + "versions": [ + "23121600", + "FFFFFFFF" + ], + "visVersion": "231216" + }, + { + "type": "Micom", + "modelId": "074340460841FFFFFFFF", + "versions": [ + "22051300", + "FFFFFFFF" + ], + "visVersion": "220513" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000", + "x.com.samsung.da.description": "MW7300B-/EU1", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "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": "24111400", + "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": "04753A23121600", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04608A22051300", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "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": "KM6", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "NoOperation", + "Autocook", + "AutocookCustom", + "Convection", + "AirFryer", + "Grill", + "MicroWave", + "MicroWaveGrill", + "MicroWaveConvection", + "Deodorization" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_MW7300B-/EU1", + "TimeAutoSync_On", + "TimeSystem_24", + "Sound_Off" + ], + "x.com.samsung.da.defaultMode": "Convection", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"NoOperation\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"Autocook\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"AutocookCustom\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"Convection\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"200\",\"tempDefaultC\":\"180\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"AirFryer\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"100\",\"tempMaxC\":\"200\",\"tempDefaultC\":\"180\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"Grill\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"00:01:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"MicroWave\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:39:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"900W\",\"powerListLength\":\"7\",\"powerListData\":[\"100W\",\"180W\",\"300W\",\"450W\",\"600W\",\"700W\",\"900W\"],\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"MicroWaveGrill\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"600W\",\"powerListLength\":\"5\",\"powerListData\":[\"100W\",\"180W\",\"300W\",\"450W\",\"600W\"],\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"MicroWaveConvection\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"200\",\"tempDefaultC\":\"180\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"600W\",\"powerListLength\":\"5\",\"powerListData\":[\"100W\",\"180W\",\"300W\",\"450W\",\"600W\"],\"tempIntervalC\":\"5\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"Deodorization\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"00:15:00\",\"timeDefault\":\"00:05:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "causeSource.state": "SETB_Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "0", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-27T08:26:55" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0W", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Celsius" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/London", + "offset": "+01:00", + "DST": "ON" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "91800", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/recipe/cook/vs/0", + "rep": { + "x.com.samsung.da.recipeDisplay": "{\"language\":\"None\",\"menu\":\"\",\"servingSize\":\"\",\"option\":\"\"}" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index a291b10..b773098 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -329,6 +329,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_oven_via_microwave_token(self): + """Issue #121: a combi microwave (MW7300B-/EU1) reports no + oneUiVersion and an unrecognized consumer token; falls back to the + '-MICROWAVE-' token in modelNum onto the *existing* oven registry + (same '/oven/vs/0' cavity + cook-mode shape), not a new device + type.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000', + 'MW7300B-/EU1', + ) + assert reg is not None + assert reg.name == 'oven' + 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 f56a9c4..b584075 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -568,6 +568,26 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil ) +def test_registry_reproduces_golden_state_keys_for_oven_mw7300b(): + """TP1X_DA-KS-MICROWAVE-01041 combi microwave (model MW7300B, issue + #121) -- reports no oneUiVersion; resolved via the '-MICROWAVE-' + modelNum token fallback onto the *existing* oven registry rather than + a new device type, since it shares the same '/oven/vs/0' cavity and + '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*). + The only href the oven registry didn't already cover was + /recipe/cook/vs/0 (oven.OVEN_RECIPE_COOK, an empty quick-recipe-display + blob with no entity).""" + from tests.conftest import _load_device + resources = _load_device('oven_mw7300b') + golden = json.loads((GOLDEN / 'oven_mw7300b.json').read_text()) + state_keys = _new_state_keys('oven_mw7300b', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 4e1eb7d03d8838a4c767009660a35c515725642a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:08:32 +0000 Subject: [PATCH 07/11] Add fan-mode control for TP1X_DA-AC-AIR air purifiers (issue #130) This newer board family reports fan modes (Smart/Max/Mid/WindFree/Sleep) directly on /mode/vs/0's top-level modes/supportedModes fields, unlike the older ARTIK051_TVTL family this registry already supported, which packs everything into an options[] array with no usable fan-speed selector at all (see the module docstring's Comode_Off finding). Both board generations share the /mode/vs/0 href, so the existing MODE capability and a new FAN capability are discriminated by a match_fn checking for the top-level supportedModes field, rather than adding a new device type. The fan entity only exposes PRESET_MODE, not an ordered percentage -- WindFree/Smart/Sleep are named behaviors, not "faster/slower" positions relative to Max/Mid, matching how the AC family's own named convenient modes are modeled as a preset rather than a speed number. Also picked up the rest of this board's previously-unbound hrefs while in there (display, HEPA filter, panel status, pet-filter mode, sound settings), reusing airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE for the two hrefs identical to the shared DA-AC- board family, since the "incomplete capability coverage" repair was firing on more than just the fan gap the issue described. --- custom_components/localthings/fan.py | 89 +++- .../registry/by_type/air_purifier.py | 32 +- .../registry/capabilities/air_purifier.py | 200 +++++++- .../localthings/translations/en.json | 70 +++ .../localthings/translations/nl.json | 70 +++ .../air_purifier_tp1x_da_ac_air_device.json | 466 ++++++++++++++++++ .../golden/air_purifier_tp1x_da_ac_air.json | 27 + tests/test_air_purifier_fan_discriminator.py | 39 ++ tests/test_golden_regression.py | 21 + 9 files changed, 1002 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/air_purifier_tp1x_da_ac_air_device.json create mode 100644 tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json create mode 100644 tests/test_air_purifier_fan_discriminator.py diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index f21905f..ff789e9 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -1,4 +1,10 @@ -"""Fan platform for Samsung range hoods.""" +"""Fan platform for Samsung range hoods and air purifiers. + +Two FanDesc-bound hrefs exist, one per family, dispatched by href in +async_setup_entry below since they need different HA fan semantics: the +range hood's fan speed is an ordered set of numeric levels (SET_SPEED), while +the newer air-purifier board family's modes (Smart/Max/Mid/WindFree/Sleep, +issue #130) are named behaviors with no linear order (PRESET_MODE).""" from __future__ import annotations @@ -14,6 +20,7 @@ from homeassistant.util.percentage import ( from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included +from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF from .registry.entities import FanDesc @@ -22,6 +29,9 @@ POWER_VS_HREF = '/power/vs/0' _FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed' _SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed' +_MODES_FIELD = 'x.com.samsung.da.modes' +_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes' + async def async_setup_entry( hass: HomeAssistant, @@ -29,11 +39,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - LocalThingsRangeHoodFan(coordinator, bound) - for bound in coordinator.bound - if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator) - ) + entities = [] + for bound in coordinator.bound: + if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)): + continue + if bound.href == AIR_PURIFIER_FAN_HREF: + entities.append(LocalThingsAirPurifierFan(coordinator, bound)) + else: + entities.append(LocalThingsRangeHoodFan(coordinator, bound)) + async_add_entities(entities) class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @@ -120,3 +134,66 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): ) code = percentage_to_ordered_list_item(codes, percentage) await self.coordinator.async_send_command(self._bound, ('speed', code)) + + +class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): + """Air-purifier fan: named preset modes, not an ordered percentage -- + see air_purifier.FAN's comment for why (Smart/WindFree/Sleep aren't + "faster/slower" than Max/Mid).""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: + super().__init__(coordinator, bound) + self._attr_name = None + + def _rep(self, href: str) -> dict: + return self.coordinator.resource(href) or {} + + def _mode_rep(self) -> dict: + return self._rep(self._bound.href) + + @property + def is_on(self) -> bool: + power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power') + if power is not None: + return str(power).lower() == 'on' + return bool(self._rep(POWER_HREF).get('value')) + + @property + def preset_modes(self) -> list[str]: + return [ + str(code).lower() + for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()) + ] + + @property + def preset_mode(self) -> str | None: + modes = self._mode_rep().get(_MODES_FIELD) + code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes + return str(code).lower() if code is not None else None + + async def async_turn_on( + self, percentage: int | None = None, preset_mode: str | None = None, + **kwargs, + ) -> None: + await self.coordinator.async_send_command(self._bound, ('power', True)) + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + + async def async_turn_off(self, **kwargs) -> None: + await self.coordinator.async_send_command(self._bound, ('power', False)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + # Reverse-resolve against the unit's own supportedModes -- the + # write needs the raw device code (e.g. 'WindFree'), not the + # lowercased HA value. + for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()): + if str(code).lower() == preset_mode: + await self.coordinator.async_send_command(self._bound, ('mode', code)) + return diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 1042455..2648aec 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -1,10 +1,22 @@ -"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56). +"""Air-purifier device registry. -Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum -token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 -(identical field/write contract). +Spans two board generations sharing this one registry (see +capabilities/air_purifier.py's module docstring for the per-href +match_fn discriminators that keep them from colliding): + +- ARTIK051_TVTL-class (issue #56). Reports no oneUiVersion; resolved via + for_device_by_model's '_TVTL_' modelNum token (see registry.py). +- TP1X_DA-AC-AIR-class (issue #130). Self-reports oneUiVersion "7.0 Air + purifier", resolved via for_device(). Adds real fan-mode control plus + display/HEPA-filter/pet-filter/sound resources the older family never + reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE + for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on + the shared DA-AC- board family. + +Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write +contract). """ -from ..capabilities import air_purifier, common, dishwasher, ignored +from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -20,6 +32,16 @@ REGISTRY = DeviceRegistry( air_purifier.AIRFLOW_GENERIC, air_purifier.AIRFLOW_VS_FALLBACK, air_purifier.MODE, + air_purifier.FAN, + air_purifier.DISPLAY, + air_purifier.HEPA_FILTER, + air_purifier.PANEL_STATUS, + air_purifier.PET_FILTER_ACTIVATION, + air_purifier.SOUND_MODE, + air_purifier.SOUND_OUTPUT, + air_purifier.SOUND_VOLUME, + airconditioner.DISPLAY_LIGHT, + airconditioner.MUTE_ONCE, *air_purifier.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 136d524..6050157 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -49,10 +49,26 @@ diagnostics snapshot was taken. Exposed read-only pending a confirmed, stable capture -- see the issue #56 discussion for what's needed. """ from ..capability import Capability -from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, +) from .common import int_or_none, sensor_item_value from .laundry import bool_option_exists, bool_option_value, option_value, option_write +# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue +# #130) report fan modes directly on /mode/vs/0's top-level `modes`/ +# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing +# everything into the options[] array the way the older ARTIK051_TVTL +# family above does -- that older family's /mode/vs/0 has no top-level +# supportedModes at all (see the module docstring's Comode_Off finding). +# Both board generations share the /mode/vs/0 href, so FAN and MODE below +# are mutually exclusive via this presence check rather than colliding. +HREF_MODE = '/mode/vs/0' + + +def _has_top_level_modes(rep, resources): + return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple)) + _AIR_QUALITY_SENSORS = ( ('dust', 'mdi:blur', 'Dust'), ('fine_dust', 'mdi:blur', 'FineDust'), @@ -156,6 +172,7 @@ def _light_write(payload, rep, href=None): MODE = Capability( href='/mode/vs/0', poll_tier='warm', + match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources), entities=( SwitchDesc(key='display_light', icon='mdi:led-on', entity_category='config', @@ -171,11 +188,192 @@ MODE = Capability( ), ) + +def _fan_write(payload, rep, href=None): + kind, value = payload + if kind == 'power': + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + if kind == 'mode': + return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]} + return None + + +def _first_fan_mode(rep): + """Representative scalar for the fan entity in the flattened state + (golden/regression), mirroring airconditioner.py's own _first_mode -- + the real entity computes its state from live coordinator reads.""" + modes = rep.get('x.com.samsung.da.modes') + if isinstance(modes, (list, tuple)): + return modes[0] if modes else None + return modes + + +# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered +# percentage -- WindFree/Smart/Sleep are named behaviors, not +# "faster/slower" positions relative to Max/Mid, so fan.py's entity for +# this only exposes PRESET_MODE, matching how the AC family's own named +# convenient modes are modeled as a preset rather than a speed number. +FAN = Capability( + href=HREF_MODE, + poll_tier='warm', + match_fn=_has_top_level_modes, + entities=( + FanDesc(key='fan', translation_key='air_purifier_fan', + rep_fn=_first_fan_mode, write_fn=_fan_write), + ), +) + +# --------------------------------------------------------------------------- +# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several +# resources the older ARTIK051_TVTL family never did. +# --------------------------------------------------------------------------- + +# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood +# light): both report the same {mode, supportedModes: [On, Off]} shape on +# separate hrefs on this dump, so they're two independent physical controls, +# not a duplicate encoding of one. +DISPLAY = Capability( + href='/display/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='display', field='mode', + icon='mdi:monitor', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['display', 'vs', '0'], + {'mode': 'On' if p == 'On' else 'Off'})), + ), +) + +# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own +# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not +# seen on this one dump, so the option list there is reused as-is rather +# than re-deriving it from a single sample. +HEPA_FILTER = Capability( + href='/filter/hepafilter/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='hepa_filter_usage', rep_fn=lambda rep: ( + round(int_or_none(rep.get('x.com.samsung.da.filterUsage')) / + int_or_none(rep.get('x.com.samsung.da.filterCapacity')) * 100) + if int_or_none(rep.get('x.com.samsung.da.filterUsage')) is not None + and int_or_none(rep.get('x.com.samsung.da.filterCapacity')) + else None + ), unit='%', state_class='measurement', + icon='mdi:air-filter', entity_category='diagnostic'), + SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus', + device_class='enum', + options=('normal', 'wash', 'replace'), + translation_key='filter_status', + icon='mdi:air-filter', entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + ), +) + +# Physical panel/cover status -- meaning of the one value seen ('Close') is +# plausible (the HEPA-filter access cover) but unconfirmed, and no +# supportedStatus list is present to check against -- exposed as a plain +# diagnostic sensor rather than an asserted binary_sensor polarity. +PANEL_STATUS = Capability( + href='/panel/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='panel_status', field='status', + icon='mdi:archive-outline', entity_category='diagnostic'), + ), +) + +# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same +# convention as airconditioner.MUTE_ONCE. +PET_FILTER_ACTIVATION = Capability( + href='/petfilteractivation/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='pet_filter_activation', field='status', + icon='mdi:paw', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['petfilteractivation', 'vs', '0'], + {'status': 'On' if p == 'On' else 'Off'})), + ), +) + +# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at +# a glance, but this board's actual values differ (supportedModes here is +# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range +# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a +# valid write ('buzzer') or expose the wrong number range, so these are +# separate descriptors reading the live supported values instead of a +# hardcoded table. +SOUND_MODE = Capability( + href='/settings/sound/mode/vs/0', + poll_tier='cold', + entities=( + SelectDesc(key='sound_mode', field='mode', + icon='mdi:volume-high', + entity_category='config', + options_field='supportedModes', + write_fn=lambda p, rep, href=None: ( + ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})), + ), +) + +# Read-only descriptor of which sound output the unit has -- only one value +# seen, no alternatives to select between. +SOUND_OUTPUT = Capability( + href='/settings/sound/output/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='sound_output', field='deviceType', + icon='mdi:volume-high', entity_category='diagnostic'), + ), +) + +SOUND_VOLUME = Capability( + href='/settings/sound/volume/vs/0', + poll_tier='cold', + entities=( + NumberDesc(key='sound_volume', field='level', + icon='mdi:volume-medium', + entity_category='config', + native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0, + native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0, + step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1, + value_fn=int_or_none, + write_fn=lambda p, rep, href=None: ( + ['settings', 'sound', 'volume', 'vs', '0'], + {'level': str(int(p))})), + ), +) + # /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has # been verified against -- covered here (not globally, per ignored.py's # module docstring) since those hrefs collide with fridge/AC schemas # elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED. +# +# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same +# resources, same shapes, same reasoning as airconditioner.py's +# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather +# than promoted to the global ignored.py list, since that would require +# also removing them from _AC_IGNORED in the same change (a global entry +# colliding with a family-local bare Capability on the same href raises in +# _build()); left as a possible follow-up DRY cleanup. COVERAGE = [ Capability(href='/humidity/0'), Capability(href='/humidity/vs/0'), + Capability(href='/airlevelcheck/vs/0'), # periodic air-quality sensing scheduler plumbing + Capability(href='/availablecontrolsets/vs/0'), # opaque hex-encoded control-set bitmap + Capability(href='/da/softreset/vs/0'), # soft-reset trigger plumbing + Capability(href='/keepnormalstate/vs/0'), # internal keep-normal flag + Capability(href='/personality/presence/vs/0'), # presence-personalization plumbing (empty here) + Capability(href='/reserverulesets/vs/0'), # opaque hex-encoded schedule reservation blob + # Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/ + # useTimeSetting/functionState) -- every field reads its inert default + # on the only dump seen (times both '00:00:00', useTimeSetting/ + # functionState both 'false'). Same "needs a multi-field schedule + # editor" treatment as fridge.py's /defrost/reservation/vs/0. + Capability(href='/dnd/autosleep/vs/0'), ] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 14c0a01..8d3b5d5 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -16,6 +16,9 @@ "cloud_connected": { "name": "Cloud connected" }, + "dustbag_full": { + "name": "Dust bag full" + }, "cooktop_power": { "name": "Cooktop power" }, @@ -131,6 +134,21 @@ } } }, + "fan": { + "air_purifier_fan": { + "state_attributes": { + "preset_mode": { + "state": { + "smart": "Smart", + "max": "Max", + "mid": "Mid", + "windfree": "WindFree", + "sleep": "Sleep" + } + } + } + } + }, "number": { "cook_time": { "name": "Cook time" @@ -188,6 +206,13 @@ "on": "On" } }, + "discharging_time": { + "name": "Discharging time", + "state": { + "1": "1 minute", + "3": "3 minutes" + } + }, "cycle": { "name": "Cycle" }, @@ -518,6 +543,36 @@ "current_limit_level": { "name": "Current limit level" }, + "hepa_filter_usage": { + "name": "HEPA filter usage" + }, + "panel_status": { + "name": "Panel status" + }, + "sound_output": { + "name": "Sound output" + }, + "dustbag_usage": { + "name": "Dust bag usage" + }, + "cleanstation_status": { + "name": "Clean station status" + }, + "stick_status": { + "name": "Stick status" + }, + "uvc_operation_time": { + "name": "UV-C operation time" + }, + "uvc_total_operation_time": { + "name": "UV-C total operation time" + }, + "uvc_finished_time": { + "name": "UV-C finished time" + }, + "uvc_emitted_time": { + "name": "UV-C emitted time" + }, "overload_protection_mode": { "name": "Overload protection mode", "state": { @@ -739,6 +794,21 @@ "auto_clean": { "name": "Auto clean" }, + "display": { + "name": "Display" + }, + "pet_filter_activation": { + "name": "Pet filter activation" + }, + "auto_empty": { + "name": "Auto empty" + }, + "dustbin_auto_close": { + "name": "Dustbin auto-close" + }, + "uvc_intensive_mode": { + "name": "UV-C intensive mode" + }, "auto_door_opener": { "name": "Auto door opener" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 454bb86..7ead2ea 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -16,6 +16,9 @@ "cloud_connected": { "name": "Verbonden met cloud" }, + "dustbag_full": { + "name": "Stofzak vol" + }, "cooktop_power": { "name": "Kookplaat voeding" }, @@ -131,6 +134,21 @@ } } }, + "fan": { + "air_purifier_fan": { + "state_attributes": { + "preset_mode": { + "state": { + "smart": "Slim", + "max": "Max", + "mid": "Gemiddeld", + "windfree": "WindFree", + "sleep": "Slaap" + } + } + } + } + }, "number": { "cook_time": { "name": "Bereidingstijd" @@ -188,6 +206,13 @@ "on": "Aan" } }, + "discharging_time": { + "name": "Leegtijd", + "state": { + "1": "1 minuut", + "3": "3 minuten" + } + }, "cycle": { "name": "Programma" }, @@ -518,6 +543,36 @@ "current_limit_level": { "name": "Niveau stroombegrenzing" }, + "hepa_filter_usage": { + "name": "HEPA-filtergebruik" + }, + "panel_status": { + "name": "Paneelstatus" + }, + "sound_output": { + "name": "Geluidsuitvoer" + }, + "dustbag_usage": { + "name": "Stofzakgebruik" + }, + "cleanstation_status": { + "name": "Status schoonmaakstation" + }, + "stick_status": { + "name": "Status steel" + }, + "uvc_operation_time": { + "name": "UV-C bedrijfstijd" + }, + "uvc_total_operation_time": { + "name": "UV-C totale bedrijfstijd" + }, + "uvc_finished_time": { + "name": "UV-C voltooid op" + }, + "uvc_emitted_time": { + "name": "UV-C uitgestraald op" + }, "overload_protection_mode": { "name": "Modus overbelastingsbeveiliging", "state": { @@ -739,6 +794,21 @@ "auto_clean": { "name": "Automatisch reinigen" }, + "display": { + "name": "Display" + }, + "pet_filter_activation": { + "name": "Huisdierfilter actief" + }, + "auto_empty": { + "name": "Automatisch legen" + }, + "dustbin_auto_close": { + "name": "Stofreservoir automatisch sluiten" + }, + "uvc_intensive_mode": { + "name": "Intensieve UV-C-modus" + }, "auto_door_opener": { "name": "Automatische deuropener" }, diff --git a/tests/fixtures/air_purifier_tp1x_da_ac_air_device.json b/tests/fixtures/air_purifier_tp1x_da_ac_air_device.json new file mode 100644 index 0000000..0098dc2 --- /dev/null +++ b/tests/fixtures/air_purifier_tp1x_da_ac_air_device.json @@ -0,0 +1,466 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785157841", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Off", + "x.com.samsung.da.supportedAutoExeState": [ + "Off", + "Airpurify", + "Alarm" + ], + "x.com.samsung.da.startSensingOnce": "Off" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-27T13:18:51", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3B000100000A0000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2650000000", + "x.com.samsung.da.airconOptionList": [ + "AI_PURIFY", + "WELCOMECARE", + "DR" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "href": "/display/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.startTime": "00:00:00", + "x.com.samsung.da.endTime": "00:00:00", + "x.com.samsung.da.useTimeSetting": "false", + "x.com.samsung.da.functionState": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1785158301", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativePower": "32428", + "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/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "70", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "8760", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ], + "x.com.samsung.da.filterStatus": "normal" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-AIR-01031_0000|10268241|70000721001813CC4D1A490301082100", + "x.com.samsung.da.description": "TP1X_DA-AC-AIR-01031_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "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": "02660A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02682A25042102,02683A25031801" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AP2", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Smart" + ], + "x.com.samsung.da.supportedModes": [ + "Smart", + "Max", + "Mid", + "WindFree", + "Sleep" + ], + "x.com.samsung.da.options": [ + "OptionCode_23418", + "SmartSleep_Off", + "WelcomeCare_On", + "Pollution_Off", + "ProtectOperation_Off", + "UpdateAllow_NotAllowed" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/panel/vs/0", + "rep": { + "status": "Close" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/petfilteractivation/vs/0", + "rep": { + "status": "Off" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "operationNumber": "36", + "displayCondition": "Enable" + } + }, + { + "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": "3F00000000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.cleanLevel": "1", + "x.com.samsung.da.total": "5", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ], + "rt": [ + "x.com.samsung.da.sensors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "mode": "mute", + "supportedModes": [ + "mute", + "buzzer" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "buzzer" + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "level": "0", + "minLevel": "0", + "maxLevel": "3", + "resolution": "1" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "12250421", + "x.com.samsung.da.currentVersionInfo": "10000000", + "otnStatus": "None", + "flashingProgress": "0", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air purifier", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "AVT-KR-TP1-24-AXXX00", + "versions": [ + "12260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "895010268241FFFFFFFF", + "versions": [ + "25042102", + "FFFFFFFF" + ], + "visVersion": "250421" + }, + { + "type": "Micom", + "modelId": "09501026824110268341", + "versions": [ + "25042102", + "25031801" + ], + "visVersion": "250421" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "Branden_Summit" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json b/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json new file mode 100644 index 0000000..b72b83a --- /dev/null +++ b/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "clean_level", + "device_active", + "display", + "display_light", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fan", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "mute_once", + "odor", + "panel_status", + "pet_filter_activation", + "power_switch", + "sound_mode", + "sound_output", + "sound_volume", + "super_fine_dust" + ] +} \ No newline at end of file diff --git a/tests/test_air_purifier_fan_discriminator.py b/tests/test_air_purifier_fan_discriminator.py new file mode 100644 index 0000000..0f61a12 --- /dev/null +++ b/tests/test_air_purifier_fan_discriminator.py @@ -0,0 +1,39 @@ +"""Tests for the /mode/vs/0 match_fn discriminator in capabilities/ +air_purifier.py (issue #130). + +`_has_top_level_modes` decides whether a given air-purifier dump's shared +/mode/vs/0 href binds to the older ARTIK051_TVTL family's MODE capability +(packed options[] scheme) or the newer TP1X_DA-AC-AIR family's FAN +capability (modes/supportedModes reported directly). Pure function, no +coordinator/entity dependency needed to test it directly -- same rationale +as test_climate_wind_oscillation_fallback.py's `_oscillation_swing`. +""" +from custom_components.localthings.registry.capabilities.air_purifier import ( + _has_top_level_modes, +) + + +def test_new_family_with_supported_modes_list_matches(): + rep = { + 'x.com.samsung.da.modes': ['Smart'], + 'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'], + } + assert _has_top_level_modes(rep, {}) is True + + +def test_old_family_with_no_supported_modes_field_does_not_match(): + """The ARTIK051_TVTL family's /mode/vs/0 packs everything into + options[] (Light_On, Comode_Off, ...) and has no top-level + supportedModes at all.""" + rep = { + 'x.com.samsung.da.options': ['Light_On', 'Comode_Off', 'OptionCode_60282'], + } + assert _has_top_level_modes(rep, {}) is False + + +def test_non_list_supported_modes_does_not_match(): + assert _has_top_level_modes({'x.com.samsung.da.supportedModes': 'Smart'}, {}) is False + + +def test_empty_rep_does_not_match(): + assert _has_top_level_modes({}, {}) is False diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index b584075..d94fc99 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -588,6 +588,27 @@ def test_registry_reproduces_golden_state_keys_for_oven_mw7300b(): ) +def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air(): + """TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion + '7.0 Air purifier' and resolves via for_device() onto the existing + air_purifier registry (shared with the older ARTIK051_TVTL family via + per-href match_fn discrimination -- see capabilities/air_purifier.py). + Its /mode/vs/0 reports modes/supportedModes directly (Smart/Max/Mid/ + WindFree/Sleep) rather than the older family's packed options[] scheme, + which the new air_purifier.FAN capability now binds to a real `fan` + entity (PRESET_MODE, not an ordered speed -- see fan.py). Binds with + zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_tp1x_da_ac_air') + golden = json.loads((GOLDEN / 'air_purifier_tp1x_da_ac_air.json').read_text()) + state_keys = _new_state_keys('air_purifier_tp1x_da_ac_air', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 8b44fd57106be835a6d8e6d4cb8c9c380b70718e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:11:06 +0000 Subject: [PATCH 08/11] Add device support for the stick-vacuum clean/auto-empty station (issue #131) A-VSKR-TP1-22-VS9500AL connects successfully but its dump shows no vacuum-body state at all -- no suction level, no battery, no cleaning mode -- only the clean/auto-empty station's own dustbag, dustbin auto-empty settings, and UV-C sanitizing-cycle status. This strongly suggests the WiFi/DTLS module lives in the station, not the handheld stick, so the station is the only "device" this integration's local API can reach at all. New vacuum_station device type (these hrefs share nothing with any existing family, so there's no shared-href ambiguity to resolve against another type) routed via a new '-VSKR-' modelNum fallback. Binds with zero unbound hrefs: dust-bag full/usage sensors, auto-empty and dustbin auto-close switches, a discharging-time select, and clean-station status including UV-C intensive mode, operation time, and finished/emitted timestamps. A couple of fields with unconfirmed exact semantics (stick_status, dustbag_usage's unit) are exposed as plain diagnostic values rather than an asserted binary/percentage meaning. --- .../localthings/registry/by_type/__init__.py | 10 +- .../registry/by_type/vacuum_station.py | 22 +++ .../registry/capabilities/vacuum_station.py | 123 +++++++++++++ tests/fixtures/golden/vacuum_station.json | 19 ++ tests/fixtures/vacuum_station_device.json | 170 ++++++++++++++++++ tests/test_by_type.py | 16 ++ tests/test_golden_regression.py | 20 +++ 7 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 custom_components/localthings/registry/by_type/vacuum_station.py create mode 100644 custom_components/localthings/registry/capabilities/vacuum_station.py create mode 100644 tests/fixtures/golden/vacuum_station.json create mode 100644 tests/fixtures/vacuum_station_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 9039566..13107d8 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -5,7 +5,7 @@ from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, induction_cooktop, oven, range as _range, range_hood, refrigerator, - washer, water_purifier, + vacuum_station, washer, water_purifier, ) __all__ = [ @@ -29,6 +29,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'range': _range.REGISTRY, 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, + 'vacuum_station': vacuum_station.REGISTRY, 'washer': washer.REGISTRY, 'water_purifier': water_purifier.REGISTRY, } @@ -250,6 +251,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # board family's. if key is None and '-COOKTOP-' in (model_num or '').upper(): key = 'induction_cooktop' + # Stick-vacuum clean/auto-empty station (e.g. A-VSKR-TP1-22-VS9500AL, + # issue #131) -- reports no oneUiVersion and carries the '-VSKR-' + # board-family token. See capabilities/vacuum_station.py for why this + # is its own device type: the resource set (dustbag/dustbin/UV-sanitize + # station state) shares no hrefs with anything else already modeled. + if key is None and '-VSKR-' in (model_num or '').upper(): + key = 'vacuum_station' # Consumer-model prefix from `description` (washer/dryer/dishwasher) -- # last, since it's the fuzziest match (a bare 2-letter prefix) and would # otherwise shadow the more specific board-family tokens above. diff --git a/custom_components/localthings/registry/by_type/vacuum_station.py b/custom_components/localthings/registry/by_type/vacuum_station.py new file mode 100644 index 0000000..80c1f3f --- /dev/null +++ b/custom_components/localthings/registry/by_type/vacuum_station.py @@ -0,0 +1,22 @@ +"""Stick-vacuum clean/auto-empty station device registry (issue #131). + +See capabilities/vacuum_station.py's module docstring for why this only +covers the station's own dustbag/dustbin/UV-sanitize state and not any +vacuum-body control (suction, battery, cleaning mode) -- the diagnostics +dump this was built from reports none of that. +""" +from ..capabilities import common, ignored, vacuum_station +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='vacuum_station', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + vacuum_station.DUSTBAG, + vacuum_station.DUSTBAG_USAGE, + vacuum_station.DUSTBIN_SETTING, + vacuum_station.CLEANSTATION_STATUS, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/vacuum_station.py b/custom_components/localthings/registry/capabilities/vacuum_station.py new file mode 100644 index 0000000..d4b5287 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/vacuum_station.py @@ -0,0 +1,123 @@ +"""Capabilities for the Samsung stick-vacuum clean/auto-empty station +(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131). + +The reporter's diagnostics dump shows no vacuum-body state at all (no +suction level, no battery, no cleaning-mode/room-mapping control, no +docked/undocked status even) -- only the clean station's own dustbag, +dustbin auto-empty settings, and UV-C sanitizing-cycle status. This +strongly suggests the WiFi/DTLS module lives in the station, not the +handheld stick: the station is the only "device" this integration's local +API can see, and the wand's own controls are unrelated hardware not +reachable this way. Modeled as its own device type -- these hrefs don't +overlap with any existing family, so there's no shared-href ambiguity to +resolve against another type (see registry/by_type/__init__.py's +docstring for that rule). + +Resources verified against the issue #131 diagnostics dump. +""" +from datetime import datetime, timezone + +from ..capability import Capability +from ..entities import BinarySensorDesc, 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 water_purifier.py's own _parse_iso_utc).""" + if not raw: + return None + try: + return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) + except ValueError: + return None + + +DUSTBAG = Capability( + href='/component/station/dustbag/vs/0', + poll_tier='warm', + entities=( + BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status', + device_class='problem', + icon='mdi:bag-personal', + value_fn=lambda v: v == 'full'), + ), +) + +# dustbagUsage/dustbagPrevUsage are raw counters with no capacity/resolution +# field alongside them to normalize into a percentage (unlike the AC/range +# families' filterUsage, which always ships filterCapacity) -- exposed as a +# plain diagnostic count rather than guessing a unit. +DUSTBAG_USAGE = Capability( + href='/component/station/dustbagusage/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage', + icon='mdi:counter', entity_category='diagnostic', + state_class='total_increasing', value_fn=int_or_none), + ), +) + +DUSTBIN_SETTING = Capability( + href='/setting/dustbin/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty', + icon='mdi:delete-empty', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['setting', 'dustbin', 'vs', '0'], + {'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})), + SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose', + icon='mdi:door-sliding', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['setting', 'dustbin', 'vs', '0'], + {'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})), + SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime', + icon='mdi:timer-outline', + entity_category='config', + options_field='x.com.samsung.da.supportedDischargingTime', + write_fn=lambda p, rep, href=None: ( + ['setting', 'dustbin', 'vs', '0'], + {'x.com.samsung.da.desiredDischargingTime': p})), + ), +) + +# stickStatus's exact meaning (docked? powered? charging?) isn't confirmed +# from this single On/Off dump -- exposed as a plain diagnostic sensor +# rather than a binary_sensor, so no polarity/semantic is asserted that +# might be wrong. +CLEANSTATION_STATUS = Capability( + href='/status/cleanstation/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status', + icon='mdi:home-lightning-bolt', entity_category='diagnostic'), + SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus', + icon='mdi:broom', entity_category='diagnostic'), + SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive', + icon='mdi:lightbulb-on-outline', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['status', 'cleanstation', 'vs', '0'], + {'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})), + SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime', + icon='mdi:timer-sand', entity_category='diagnostic', + value_fn=int_or_none), + SensorDesc(key='uvc_total_operation_time', + field='x.com.samsung.da.uvcTotalOperationTime', + icon='mdi:timer-sand', entity_category='diagnostic', + state_class='total_increasing', value_fn=int_or_none), + SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime', + device_class='timestamp', entity_category='diagnostic', + value_fn=_parse_iso_utc), + SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime', + device_class='timestamp', entity_category='diagnostic', + value_fn=_parse_iso_utc), + ), +) diff --git a/tests/fixtures/golden/vacuum_station.json b/tests/fixtures/golden/vacuum_station.json new file mode 100644 index 0000000..ae896c2 --- /dev/null +++ b/tests/fixtures/golden/vacuum_station.json @@ -0,0 +1,19 @@ +{ + "state_keys": [ + "alarm_code", + "auto_empty", + "cleanstation_status", + "discharging_time", + "dustbag_full", + "dustbag_usage", + "dustbin_auto_close", + "energy_kwh", + "firmware_update", + "stick_status", + "uvc_emitted_time", + "uvc_finished_time", + "uvc_intensive_mode", + "uvc_operation_time", + "uvc_total_operation_time" + ] +} \ No newline at end of file diff --git a/tests/fixtures/vacuum_station_device.json b/tests/fixtures/vacuum_station_device.json new file mode 100644 index 0000000..962a252 --- /dev/null +++ b/tests/fixtures/vacuum_station_device.json @@ -0,0 +1,170 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.timeforlongnoti": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "915", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000", + "x.com.samsung.da.description": "A-VSKR-TP1-22-VS9500AL", + "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": "VS1", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "023741250507", + "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": "25050800", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "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": "2650000000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "CLEAN_OPTION_SUPPORT" + ] + } + }, + { + "href": "/component/station/dustbag/vs/0", + "rep": { + "x.com.samsung.da.status": "normal", + "x.com.samsung.da.supportedStatus": [ + "full", + "normal" + ] + } + }, + { + "href": "/setting/dustbin/vs/0", + "rep": { + "x.com.samsung.da.autoEmpty": "On", + "x.com.samsung.da.autoClose": "On", + "x.com.samsung.da.desiredDischargingTime": "3", + "x.com.samsung.da.supportedDischargingTime": [ + "1", + "3" + ] + } + }, + { + "href": "/status/cleanstation/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.uvcTotalOperationTime": "90", + "x.com.samsung.da.uvcOperationTime": "90", + "x.com.samsung.da.uvcFinishedTime": "2026-07-27T10:26Z", + "x.com.samsung.da.emittedTime": "2026-07-26T12:26Z", + "x.com.samsung.da.uvcIntensive": "Off", + "x.com.samsung.da.stickStatus": "On" + } + }, + { + "href": "/component/station/dustbagusage/vs/0", + "rep": { + "x.com.samsung.da.dustbagUsage": "506", + "x.com.samsung.da.dustbagPrevUsage": "0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index b773098..15ac1e2 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -343,6 +343,22 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'oven' + def test_vacuum_station_via_vskr_token(self): + """Issue #131: a stick-vacuum clean/auto-empty station + (A-VSKR-TP1-22-VS9500AL) reports no oneUiVersion and no + washer/dryer/dishwasher consumer prefix; falls back to the + '-VSKR-' token in modelNum onto a new vacuum_station registry (its + dump has no vacuum-body state at all, only station-specific + dustbag/dustbin/UV-sanitize resources -- see + capabilities/vacuum_station.py's module docstring).""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000', + 'A-VSKR-TP1-22-VS9500AL', + ) + assert reg is not None + assert reg.name == 'vacuum_station' + 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 d94fc99..96f7c32 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -609,6 +609,26 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air() ) +def test_registry_reproduces_golden_state_keys_for_vacuum_station(): + """A-VSKR-TP1-22-VS9500AL stick-vacuum clean/auto-empty station (issue + #131) -- reports no oneUiVersion; resolved via the new '-VSKR-' + modelNum fallback onto a new vacuum_station registry (see + capabilities/vacuum_station.py's module docstring for why this needed + a new device type rather than reusing an existing one: the dump has no + vacuum-body state at all, only station-specific dustbag/dustbin/ + UV-sanitize resources that share no hrefs with anything else already + modeled). Binds with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('vacuum_station') + golden = json.loads((GOLDEN / 'vacuum_station.json').read_text()) + state_keys = _new_state_keys('vacuum_station', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From d26e7b957b496f9dcd94ee38573f45ba6a265eb3 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:41:43 +0000 Subject: [PATCH 09/11] Fix unreachable reconnect-warning threshold (review follow-up, issue #119) The 60s/5-reconnect window from the original fix could never actually fire: consecutive reconnect attempts are never closer together than one summary poll interval (30s) plus the 5s reconnect pause, so at most ~2 timestamps can ever land inside a 60s window regardless of how unhealthy the connection is. That silently downgraded every reconnect to INFO permanently, including the persistently-broken case the change was supposed to still surface at WARNING. Widen to a 300s window with a threshold of 3, which is reachable under sustained failures and still a reasonable proxy for the README's "actually broken" case. --- custom_components/localthings/coordinator.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index b483efe..27e6c7e 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -90,11 +90,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # drops the DTLS session briefly every now and then, and the # coordinator recovering from that on its own isn't something a user # needs to see at WARNING. Only escalate once reconnects pile up - # within a short window, matching the README's own "more than a - # handful per minute" definition of an actually-broken connection - # (issue #119). - _RECONNECT_WARN_WINDOW_S: float = 60.0 - _RECONNECT_WARN_THRESHOLD: int = 5 + # within a trailing window (issue #119). + # + # The window can't be a literal 60s: consecutive reconnect attempts are + # never closer together than one summary poll interval (SUMMARY_INTERVAL_S, + # 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s + # window regardless of how unhealthy the connection is -- a threshold of + # 5 there could never fire, silently downgrading every reconnect + # (including a persistently broken one) to INFO forever. 300s/3 instead: + # reachable under normal polling, and 3 reconnects inside 5 minutes is + # still a reasonable proxy for the README's "actually broken" case. + _RECONNECT_WARN_WINDOW_S: float = 300.0 + _RECONNECT_WARN_THRESHOLD: int = 3 # A block-level ACK timeout on the summary GET doesn't prove the # session is dead (see _poll_once) — require this many in a row From f37c9ec4ef015c582acde74aa205b5d6c5067cb8 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:42:34 +0000 Subject: [PATCH 10/11] Fix air-purifier fan power write and silent preset-mode rejection (review follow-up, issue #130) - The fan's power write hardcoded /power/vs/0 while is_on already read /power/0 as a fallback -- a board reporting only the OCF resource would show correct state but silently no-op on every turn-on/off. Mirrors LocalThingsRangeHoodFan's existing _power_payload pattern: target whichever power href the board actually reports. - async_set_preset_mode fell off the loop silently on an unmatched mode with no log and no error, unlike the rest of this codebase's write-rejection handling. Logs a warning now. - Deduplicated HEPA_FILTER's usage-percent calculation, which was an inline reimplementation of airconditioner._filter_usage_percent; promoted the shared logic to common.filter_usage_percent and pointed both families at it. - Gave air_purifier.SOUND_MODE its own translation_key instead of defaulting to the same catalog entry laundry.SOUND_MODE uses. That entry's state table is {voice, tone, mute}; this board's is {mute, buzzer} -- sharing it left 'buzzer' with no label. --- custom_components/localthings/fan.py | 21 ++++++++++++-- .../registry/capabilities/air_purifier.py | 28 ++++++++++++------- .../registry/capabilities/airconditioner.py | 16 ++--------- .../registry/capabilities/common.py | 13 +++++++++ .../localthings/translations/en.json | 7 +++++ .../localthings/translations/nl.json | 7 +++++ 6 files changed, 66 insertions(+), 26 deletions(-) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index ff789e9..15ac111 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -8,6 +8,8 @@ issue #130) are named behaviors with no linear order (PRESET_MODE).""" from __future__ import annotations +import logging + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -23,6 +25,7 @@ from .entity import LocalThingsEntity, _is_included from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF from .registry.entities import FanDesc +_LOGGER = logging.getLogger(__name__) POWER_HREF = '/power/0' POWER_VS_HREF = '/power/vs/0' @@ -158,6 +161,16 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): def _mode_rep(self) -> dict: return self._rep(self._bound.href) + def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: + """Target whichever power resource this unit actually exposes -- + same pattern as LocalThingsRangeHoodFan._power_payload above. + Writing a hardcoded href here would silently no-op on a board that + only reports the other one, even though is_on already falls back + correctly.""" + resources = self.coordinator.last_resources + target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF + return 'power', enabled, target + @property def is_on(self) -> bool: power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power') @@ -182,12 +195,12 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: - await self.coordinator.async_send_command(self._bound, ('power', True)) + await self.coordinator.async_send_command(self._bound, self._power_payload(True)) if preset_mode is not None: await self.async_set_preset_mode(preset_mode) async def async_turn_off(self, **kwargs) -> None: - await self.coordinator.async_send_command(self._bound, ('power', False)) + await self.coordinator.async_send_command(self._bound, self._power_payload(False)) async def async_set_preset_mode(self, preset_mode: str) -> None: # Reverse-resolve against the unit's own supportedModes -- the @@ -197,3 +210,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): if str(code).lower() == preset_mode: await self.coordinator.async_send_command(self._bound, ('mode', code)) return + _LOGGER.warning( + "%s: %r is not a valid preset mode (supported: %s)", + self.entity_id, preset_mode, self.preset_modes, + ) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 6050157..5e867d6 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -52,7 +52,7 @@ from ..capability import Capability from ..entities import ( BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) -from .common import int_or_none, sensor_item_value +from .common import filter_usage_percent, int_or_none, sensor_item_value from .laundry import bool_option_exists, bool_option_value, option_value, option_write # Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue @@ -190,8 +190,15 @@ MODE = Capability( def _fan_write(payload, rep, href=None): - kind, value = payload + kind, value, *args = payload if kind == 'power': + # Targets whichever power href fan.py's _power_payload picked (the + # board may only report /power/0) -- a hardcoded vendor href here + # would silently no-op on such a board even though the entity's + # own is_on already falls back to reading it correctly. + power_href = args[0] if args else '/power/vs/0' + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} return (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if value else 'Off'}) if kind == 'mode': @@ -255,13 +262,8 @@ HEPA_FILTER = Capability( href='/filter/hepafilter/vs/0', poll_tier='cold', entities=( - SensorDesc(key='hepa_filter_usage', rep_fn=lambda rep: ( - round(int_or_none(rep.get('x.com.samsung.da.filterUsage')) / - int_or_none(rep.get('x.com.samsung.da.filterCapacity')) * 100) - if int_or_none(rep.get('x.com.samsung.da.filterUsage')) is not None - and int_or_none(rep.get('x.com.samsung.da.filterCapacity')) - else None - ), unit='%', state_class='measurement', + SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent, + unit='%', state_class='measurement', icon='mdi:air-filter', entity_category='diagnostic'), SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus', device_class='enum', @@ -312,7 +314,13 @@ SOUND_MODE = Capability( href='/settings/sound/mode/vs/0', poll_tier='cold', entities=( - SelectDesc(key='sound_mode', field='mode', + # Distinct translation_key from laundry.SOUND_MODE's shared + # 'sound_mode' catalog entry -- that one's state table is + # {voice, tone, mute}, but this board's supportedModes is + # {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled + # (falls through to the raw code) since the catalogs don't overlap. + SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode', + field='mode', icon='mdi:volume-high', entity_category='config', options_field='supportedModes', diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index c8e9576..91f0466 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -16,7 +16,7 @@ by_type registry. """ from ..capability import Capability from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc -from .common import normalize_temp_unit +from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write # --------------------------------------------------------------------------- @@ -77,18 +77,6 @@ def _temps_vs_unit(rep): return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C') -def _filter_usage_percent(rep): - """Filter usage as a percentage of rated capacity. The device reports - `filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g. - 100 of a 500 capacity), so a plain value with a '%' unit would be wrong -- - normalize to used/capacity. Returns None when capacity is missing/zero.""" - used = _num(rep.get('x.com.samsung.da.filterUsage')) - cap = _num(rep.get('x.com.samsung.da.filterCapacity')) - if used is None or not cap: - return None - return round(used / cap * 100) - - def _first_mode(rep): """Representative scalar for the climate entity in the flattened state (golden/regression). The real entity computes hvac_mode from power + mode.""" @@ -251,7 +239,7 @@ AIR_FILTER = Capability( href='/filter/airdustfilter/vs/0', poll_tier='cold', entities=( - SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent, + SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent, unit='%', state_class='measurement', icon='mdi:air-filter', entity_category='diagnostic'), SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus', diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 2b6dc59..9d519ff 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -40,6 +40,19 @@ def wh_to_kwh(v): return round(n / 1000.0, 2) if n is not None else None +def filter_usage_percent(rep): + """Filter usage as a percentage of rated capacity. Several families + (AC, air purifier) report `filterUsage` as a raw count in + `filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain + value with a '%' unit would be wrong -- normalize to used/capacity. + Returns None when capacity is missing/zero.""" + used = _num(rep.get('x.com.samsung.da.filterUsage')) + cap = _num(rep.get('x.com.samsung.da.filterCapacity')) + if used is None or not cap: + return None + return round(used / cap * 100) + + def normalize_temp_unit(raw, default='°F'): """'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to `default` for any other/missing value. Shared by fridge.py and oven.py, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 8d3b5d5..2b5c321 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -413,6 +413,13 @@ "mute": "Mute" } }, + "air_purifier_sound_mode": { + "name": "Sound mode", + "state": { + "mute": "Mute", + "buzzer": "Buzzer" + } + }, "spin_speed": { "name": "Spin speed", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 7ead2ea..9699b1f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -413,6 +413,13 @@ "mute": "Dempen" } }, + "air_purifier_sound_mode": { + "name": "Geluidsmodus", + "state": { + "mute": "Dempen", + "buzzer": "Zoemer" + } + }, "spin_speed": { "name": "Centrifugesnelheid", "state": { From e9eb38740d942a05d07a2bc1f9bcca1c48cd881e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:43:02 +0000 Subject: [PATCH 11/11] Deduplicate ISO-timestamp parsing and document the new device type (review follow-up, issue #131) - vacuum_station._parse_iso_utc was a verbatim copy of water_purifier._parse_iso_utc; promoted to common.parse_iso_utc and pointed both families at it. Also made it tzinfo-aware rather than unconditionally overwriting with UTC -- harmless today since every dump seen is a bare or Z-suffixed UTC timestamp, but a board that ever emits a real offset would otherwise have it silently clobbered. - Added the new vacuum_station type to the README's supported-appliance table, and noted that combi microwaves route through the oven registry. --- README.md | 3 ++- .../registry/capabilities/common.py | 18 ++++++++++++++++++ .../registry/capabilities/vacuum_station.py | 16 +--------------- .../registry/capabilities/water_purifier.py | 16 +--------------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 0c9c89e..e9881b3 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` | | Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | -| Oven | `by_type/oven.py` | +| Oven (including combi microwaves) | `by_type/oven.py` | | Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | @@ -33,6 +33,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | | Water purifier | `by_type/water_purifier.py` | +| Vacuum clean/auto-empty station | `by_type/vacuum_station.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/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 9d519ff..cd03a91 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -10,6 +10,8 @@ against live device dumps: /water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ +from datetime import datetime, timezone + from ..capability import Capability from ..entities import ( BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc, @@ -40,6 +42,22 @@ def wh_to_kwh(v): return round(n / 1000.0, 2) if n is not None else None +def parse_iso_utc(raw): + """ISO datetime defaulting to UTC when the string carries no timezone + of its own (this integration's convention for other bare ISO datetime + fields -- see washer.py's drum-clean-log comment). A few boards do + ship a 'Z'/offset suffix (fromisoformat parses that natively since + Python 3.11) -- only fill in UTC when parsing left the result naive, + rather than unconditionally overwriting whatever offset was parsed.""" + if not raw: + return None + try: + dt = datetime.fromisoformat(raw) + except ValueError: + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + def filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. Several families (AC, air purifier) report `filterUsage` as a raw count in diff --git a/custom_components/localthings/registry/capabilities/vacuum_station.py b/custom_components/localthings/registry/capabilities/vacuum_station.py index d4b5287..090ed88 100644 --- a/custom_components/localthings/registry/capabilities/vacuum_station.py +++ b/custom_components/localthings/registry/capabilities/vacuum_station.py @@ -15,23 +15,9 @@ docstring for that rule). Resources verified against the issue #131 diagnostics dump. """ -from datetime import datetime, timezone - from ..capability import Capability from ..entities import BinarySensorDesc, 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 water_purifier.py's own _parse_iso_utc).""" - if not raw: - return None - try: - return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) - except ValueError: - return None +from .common import int_or_none, parse_iso_utc as _parse_iso_utc DUSTBAG = Capability( diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index ef287cb..953cd3d 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -3,23 +3,9 @@ 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 +from .common import int_or_none, parse_iso_utc as _parse_iso_utc DISPENSE = Capability(