diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index f4f1672..9b42c58 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -87,6 +87,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = { 'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate 'TVTL': 'air_purifier', # issue #56 (ARTIK051) 'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air) + 'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a + # next-gen BESPOKE Cube Air board; same + # lineage as VTWW above but the '-WW-' + # delimiter now falls one letter to the + # left ('A-VTWW-' -> 'AVT-WW-'), splitting + # into a token the existing entry can't see 'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR) 'WATERPURIFIER': 'water_purifier', # issue #90 'ADW': 'dishwasher', diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 683496e..50ef840 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -16,6 +16,11 @@ match_fn discriminators that keep them from colliding): token, added for it. Its fan is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on /mode/vs/0 -- see that capability's comment. +- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW + lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter + falls one letter to the left of 'VTWW's whole-token spelling. Same + resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities + needed. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). diff --git a/tests/fixtures/air_purifier_avt_ww_device.json b/tests/fixtures/air_purifier_avt_ww_device.json new file mode 100644 index 0000000..588713e --- /dev/null +++ b/tests/fixtures/air_purifier_avt_ww_device.json @@ -0,0 +1,381 @@ +{ + "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.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.periodicSensingInterval": "600", + "x.com.samsung.da.startSensingOnce": "On", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785320820", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.autoExeState": "Off", + "x.com.samsung.da.supportedAutoExeState": [ + "Off", + "Airpurify", + "Alarm" + ], + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "03002300" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": {} + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "87", + "x.com.samsung.da.supportedModes": [ + "87", + "89", + "90", + "91" + ], + "x.com.samsung.da.modesName": [ + "SMART", + "MAX", + "WINDFREE", + "Sleep" + ] + } + }, + { + "href": "/filter/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "30", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "8760", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.startTime": "15:00:00", + "x.com.samsung.da.endTime": "23:30:00", + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.useTimeSetting": "true", + "x.com.samsung.da.functionState": "true" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "0000000000000000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "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-29T10:34:52", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "48656", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeDate": "1785353679", + "x.com.samsung.da.cumulativeDateUTC": "1785321279", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "CountOfSlave_0", + "SlavePower01_NotSupported", + "Pollution_Off", + "SmartSave_Off", + "OptionCode_23418", + "SmartSleep_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "10", + "1" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "9", + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "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": "3", + "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": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "6", + "1" + ] + } + ], + "x.com.samsung.da.total": "5" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000", + "x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500", + "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": "AC0", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02530A260327", + "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": "22120702,22022301", + "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.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "WELCOMECARE", + "AI_PURIFY" + ], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "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": "AVT-WW-TP1-23-AXX500", + "versions": [ + "12260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + } + ] + } + }, + { + "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": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "ASUS_2.4G" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/air_purifier_avt_ww.json b/tests/fixtures/golden/air_purifier_avt_ww.json new file mode 100644 index 0000000..8f13b0e --- /dev/null +++ b/tests/fixtures/golden/air_purifier_avt_ww.json @@ -0,0 +1,19 @@ +{ + "state_keys": [ + "alarm_code", + "clean_level", + "device_active", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "mute_once", + "odor", + "power_switch", + "super_fine_dust", + "wind_strength_fan" + ] +} \ No newline at end of file diff --git a/tests/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py new file mode 100644 index 0000000..83e692b --- /dev/null +++ b/tests/test_air_purifier_avt_ww.py @@ -0,0 +1,62 @@ +"""Tests for the AVT-WW-TP1-23-AXX500 air-purifier profile (issue #190). + +Next-gen BESPOKE Cube Air board -- same resource surface as the +A-VTWW-TP2-21-COMMON family (issue #151), but the '-WW-' delimiter now falls +one letter to the left ('A-VTWW-' -> 'AVT-WW-'), splitting into an 'AVT'/'WW' +token pair the existing 'VTWW' whole-token entry can't see. The reporter's +diagnostics showed device_type 'unknown' with empty oneUiVersion and every +resource unbound -- once routed to the existing air_purifier registry, every +resource here binds with zero gaps. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _resources(): + return _load_device('air_purifier_avt_ww') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def test_resolves_to_air_purifier_registry(): + assert _reg(_resources()).name == 'air_purifier' + + +def test_no_unbound_hrefs(): + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_wind_strength_fan_preset_names(): + """This dump's /wind/strength/vs/0 reports the same numeric-code + + modesName shape as the original VTWW fixture (SMART/MAX/WINDFREE/Sleep), + confirming the shared WIND_STRENGTH_FAN capability applies unchanged.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['wind_strength_fan'] == '87' + wind = resources['/wind/strength/vs/0'] + assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep'] + + +def test_air_quality_and_filter_sensors_present(): + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['dust'] == 10 + assert state['fine_dust'] == 9 + assert state['odor'] == 1 + assert state['hepa_filter_usage'] == 0 + assert state['hepa_filter_status'] == 'normal' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4b24bed..8502a91 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): + """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; + reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a + whole token doesn't match this board's 'AVT'/'WW' split. Resolved via the + new 'AVT' modelNum board-token fallback into the existing air_purifier + registry. Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_avt_ww') + golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text()) + state_keys = _new_state_keys('air_purifier_avt_ww', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_energy(): """AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board generation as the artik051_krac_18k fixture, but this dump has a nonzero