From b121d24966d6da8bab371d709e22aa635ad52cb3 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 22:12:15 +0000 Subject: [PATCH] Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56) Adds a by_type registry for the AX60R5080WD/SE air purifier family, verified against two independent diagnostics dumps (issue #56 and its comment). Binds power, alarms, energy, diagnosis (reusing dishwasher.DIAGNOSIS), the dust/ fine-dust/super-fine-dust/odor/clean-level sensors off /sensors/vs/0, filter progress, a device-active diagnostic, and a display-light switch parsed out of /mode/vs/0's packed options list. Fan speed/direction (/airflow/0, /airflow/vs/0) and two other /mode/vs/0 tokens (Comode_*, Blooming_*) are exposed as read-only diagnostics rather than full controls -- neither dump has a supported-values list to confirm their write contracts, so they're left for a follow-up once that's clarified in the issue thread. Hoists range_hood's items[]-sensor-value helper into common.py (sensor_item_value) since air_purifier now reads the same /sensors/vs/0 shape. --- README.md | 1 + .../localthings/registry/by_type/__init__.py | 10 +- .../registry/by_type/air_purifier.py | 35 +++ .../registry/capabilities/air_purifier.py | 179 ++++++++++++++++ .../registry/capabilities/common.py | 20 ++ .../registry/capabilities/range_hood.py | 21 +- tests/fixtures/air_purifier_device.json | 200 ++++++++++++++++++ tests/fixtures/golden/air_purifier.json | 25 +++ tests/test_air_purifier_capabilities.py | 107 ++++++++++ tests/test_golden_regression.py | 14 ++ 10 files changed, 594 insertions(+), 18 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/air_purifier.py create mode 100644 custom_components/localthings/registry/capabilities/air_purifier.py create mode 100644 tests/fixtures/air_purifier_device.json create mode 100644 tests/fixtures/golden/air_purifier.json create mode 100644 tests/test_air_purifier_capabilities.py diff --git a/README.md b/README.md index 3cb8c49..cc0e27f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Type | Registry | |---|---| | Air conditioner | `by_type/airconditioner.py` | +| Air purifier | `by_type/air_purifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | | Cooktop (read-only burner status) | `by_type/cooktop.py` | diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 37fc085..0939dc3 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,8 +3,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - airconditioner, cooktop, dishwasher, dryer, oven, range as _range, - range_hood, refrigerator, washer, + air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, + range as _range, range_hood, refrigerator, washer, ) __all__ = [ @@ -14,6 +14,8 @@ __all__ = [ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'air_purifier': air_purifier.REGISTRY, + 'airpurifier': air_purifier.REGISTRY, 'airconditioner': airconditioner.REGISTRY, 'air_conditioner': airconditioner.REGISTRY, 'cooktop': cooktop.REGISTRY, @@ -111,6 +113,10 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # 'P' sits between the underscore and 'RAC' in that token). if key is None and '_RAC_' in (model_num or ''): key = 'airconditioner' + # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no + # oneUiVersion either, and carry the '_TVTL_' board-family token. + if key is None and '_TVTL_' in (model_num or ''): + key = 'air_purifier' model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py new file mode 100644 index 0000000..1dee48b --- /dev/null +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -0,0 +1,35 @@ +"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56). + +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). + +/humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has +been verified against -- ignored here rather than globally since those hrefs +collide with fridge/AC schemas elsewhere (see ignored.py's module docstring). +""" +from ..capabilities import air_purifier, common, dishwasher, ignored +from ..capability import Capability +from ._base import DeviceRegistry, _build + +_HUMIDITY_EMPTY = [ + Capability(href='/humidity/0'), + Capability(href='/humidity/vs/0'), +] + +REGISTRY = DeviceRegistry( + name='air_purifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + dishwasher.DIAGNOSIS, + air_purifier.AIR_QUALITY, + air_purifier.FILTER, + air_purifier.DEVICE_ACTIVE, + air_purifier.AIRFLOW_GENERIC, + air_purifier.AIRFLOW_VS_FALLBACK, + air_purifier.MODE, + *_HUMIDITY_EMPTY, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py new file mode 100644 index 0000000..65bac1a --- /dev/null +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -0,0 +1,179 @@ +"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family +(model AX60R5080WD/SE, issue #56 -- verified against two independent +diagnostics dumps of the same internal model). + +Power, kids-lock, remote-control, alarms, and the energy meter are the shared +common.py capabilities (this family exposes the standard /power/0+/power/vs/0 +pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses +dishwasher.DIAGNOSIS -- identical field/write contract +(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps). + +Two things are deliberately left as raw, unwritable diagnostic sensors rather +than modeled as real controls, per the "don't guess" rule: + + /airflow/0, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/ + direction, both zeroed/'Off' on every dump seen (device was off in both). + No supportedSpeed/supportedModes list is present anywhere in either dump + to confirm the valid range, so a write-capable fan/select isn't safe to + build yet -- see the issue #56 request for a running-state dump. + + /mode/vs/0's x.com.samsung.da.options array packs multiple independent + flags into one list (same shape as fridge.FLEX_ZONE's `modes` field, but + keyed `options` here and, unlike FLEX_ZONE, with no `supportedOptions` + list to check membership against). Of the tokens seen: + Light_On / Light_Off -- read as a plain on/off flag; MODE_LIGHT + below models it as a real switch, RMW- + replacing just that one list entry. + Comode_Off -- never seen non-'Off' on these dumps; + likely the fan operating mode the issue + describes (Auto/Sleep/1/2/3), but + unconfirmed -- exposed read-only. + Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only. + OptionCode_60282 -- opaque, unchanged across both dumps; + not modeled (same treatment as + range_hood's OptionCode_* token on the + same href). +""" +from ..capability import Capability +from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from .common import sensor_item_value + + +def _int_or_none(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +AIR_QUALITY = Capability( + href='/sensors/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='dust', field='x.com.samsung.da.items', + name='Dust', icon='mdi:blur', + value_fn=lambda items: sensor_item_value(items, 'Dust')), + SensorDesc(key='fine_dust', field='x.com.samsung.da.items', + name='Fine dust', icon='mdi:blur', + value_fn=lambda items: sensor_item_value(items, 'FineDust')), + SensorDesc(key='super_fine_dust', field='x.com.samsung.da.items', + name='Super fine dust', icon='mdi:blur', + value_fn=lambda items: sensor_item_value(items, 'SuperFineDust')), + SensorDesc(key='odor', field='x.com.samsung.da.items', + name='Odor', icon='mdi:scent', + value_fn=lambda items: sensor_item_value(items, 'Odor')), + SensorDesc(key='clean_level', field='x.com.samsung.da.items', + name='Clean level', icon='mdi:air-filter', + value_fn=lambda items: sensor_item_value(items, 'CleanLevel')), + ), +) + +# x.com.samsung.da.items here is a single-entry {name, state} pair rather than +# the {type, value} shape AIR_QUALITY reads above -- a different schema on the +# same 'items' field name. FilterProgress is a raw 0-100 percentage in both +# dumps (100 and 62); which end of that scale means "replace me" isn't +# confirmed from the dump alone, so the entity is named after the raw field +# rather than asserting a direction (see issue #56 follow-up questions). +FILTER = Capability( + href='/consumable/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='filter_progress', field='x.com.samsung.da.items', + name='Filter progress', unit='%', state_class='measurement', + icon='mdi:air-filter', entity_category='diagnostic', + value_fn=lambda items: _int_or_none(next( + (i.get('x.com.samsung.da.state') for i in (items or ()) + if isinstance(i, dict) + and i.get('x.com.samsung.da.name') == 'FilterProgress'), + None))), + ), +) + +DEVICE_ACTIVE = Capability( + href='/devicespecificinfo/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive', + name='Device active', icon='mdi:check-network-outline', + entity_category='diagnostic', + value_fn=lambda v: bool(v)), + ), +) + +# OCF-native / vendor pair for fan speed+direction -- see module docstring for +# why these are read-only for now. +AIRFLOW_GENERIC = Capability( + href='/airflow/0', + poll_tier='warm', + entities=( + SensorDesc(key='fan_speed_level', field='speed', + name='Fan speed level', icon='mdi:fan', + state_class='measurement', entity_category='diagnostic'), + SensorDesc(key='fan_direction', field='direction', + name='Fan direction', icon='mdi:rotate-3d-variant', + entity_category='diagnostic'), + ), +) + +AIRFLOW_VS_FALLBACK = Capability( + href='/airflow/vs/0', + match_fn=lambda rep, resources: '/airflow/0' not in resources, + poll_tier='warm', + entities=( + SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel', + name='Fan speed level', icon='mdi:fan', + state_class='measurement', entity_category='diagnostic', + value_fn=_int_or_none), + SensorDesc(key='fan_direction', field='x.com.samsung.da.direction', + name='Fan direction', icon='mdi:rotate-3d-variant', + entity_category='diagnostic'), + ), +) + + +def _mode_options(rep): + opts = rep.get('x.com.samsung.da.options') + return list(opts) if isinstance(opts, (list, tuple)) else [] + + +def _mode_token(rep, prefix): + """Value after `prefix` from the first matching entry in the packed + options list, or None if no entry carries that prefix.""" + for opt in _mode_options(rep): + if isinstance(opt, str) and opt.startswith(prefix): + return opt[len(prefix):] + return None + + +def _mode_has_prefix(prefix): + return lambda rep, resources: _mode_token(rep, prefix) is not None + + +def _light_write(payload, rep, href=None): + new_token = f"Light_{'On' if payload == 'On' else 'Off'}" + opts = [o for o in _mode_options(rep) + if not (isinstance(o, str) and o.startswith('Light_'))] + opts.append(new_token) + return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts} + + +MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on', + entity_category='config', + rep_fn=lambda rep: _mode_token(rep, 'Light_') == 'On', + exists_fn=_mode_has_prefix('Light_'), + write_fn=_light_write), + # Read-only pending issue #56 follow-up -- see module docstring. + SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan', + entity_category='diagnostic', + rep_fn=lambda rep: _mode_token(rep, 'Comode_'), + exists_fn=_mode_has_prefix('Comode_')), + SensorDesc(key='blooming_level', name='Blooming level', icon='mdi:flower', + entity_category='diagnostic', + rep_fn=lambda rep: _mode_token(rep, 'Blooming_'), + exists_fn=_mode_has_prefix('Blooming_')), + ), +) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index bb1775b..31b45ac 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -58,6 +58,26 @@ def _active_alarm_codes(items): return ', '.join(codes) if codes else 'none' +def sensor_item_value(items, sensor_type, index=0): + """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each + item is `{type, value: [...]}`; `index` picks which slot of a possibly + multi-value reading to read (index 0 is the raw measurement on every + family seen so far). Shared by range_hood.AIR_QUALITY and + air_purifier.AIR_QUALITY, which read the same resource shape.""" + for item in items or (): + if not isinstance(item, dict): + continue + if item.get('x.com.samsung.da.type') != sensor_type: + continue + values = item.get('x.com.samsung.da.value') or () + if index < len(values): + try: + return int(values[index]) + except (TypeError, ValueError): + return None + return None + + # OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control. # # These three controls exist as both a standard OCF resource (/power/0, diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 8b0d96f..b397160 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -17,6 +17,7 @@ from ..entities import ( SensorDesc, SwitchDesc, ) +from .common import sensor_item_value def _int_or_none(value): @@ -33,18 +34,6 @@ def _timestamp(value): return None -def _item_value(items, sensor_type, index=0): - for item in items or (): - if not isinstance(item, dict): - continue - if item.get('x.com.samsung.da.type') != sensor_type: - continue - values = item.get('x.com.samsung.da.value') or () - if index < len(values): - return _int_or_none(values[index]) - return None - - def _active_alarm_codes(items): """Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row. @@ -208,25 +197,25 @@ AIR_QUALITY = Capability( field='x.com.samsung.da.items', name='Clean level', icon='mdi:air-filter', - value_fn=lambda items: _item_value(items, 'CleanLevel'), + value_fn=lambda items: sensor_item_value(items, 'CleanLevel'), ), SensorDesc( key='dust', field='x.com.samsung.da.items', name='Dust', - value_fn=lambda items: _item_value(items, 'Dust'), + value_fn=lambda items: sensor_item_value(items, 'Dust'), ), SensorDesc( key='fine_dust', field='x.com.samsung.da.items', name='Fine dust', - value_fn=lambda items: _item_value(items, 'FineDust'), + value_fn=lambda items: sensor_item_value(items, 'FineDust'), ), SensorDesc( key='super_fine_dust', field='x.com.samsung.da.items', name='Super fine dust', - value_fn=lambda items: _item_value(items, 'SuperFineDust'), + value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'), ), ), ) diff --git a/tests/fixtures/air_purifier_device.json b/tests/fixtures/air_purifier_device.json new file mode 100644 index 0000000..0a543ad --- /dev/null +++ b/tests/fixtures/air_purifier_device.json @@ -0,0 +1,200 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/0", + "rep": { + "speed": 0, + "direction": "Off" + } + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "0", + "x.com.samsung.da.direction": "Off" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55", + "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", + "x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55", + "x.com.samsung.da.state": "Created" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/consumable/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.state": "100", + "x.com.samsung.da.name": "FilterProgress" + } + ] + } + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": {} + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000", + "x.com.samsung.da.description": "ARTIK051_TVTL_18K", + "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": "02059A230513", + "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": "19071703,19071706", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "Comode_Off", + "Blooming_0", + "Light_On", + "OptionCode_60282" + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "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": [ + "11", + "0" + ] + }, + { + "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", + "0" + ] + }, + { + "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": [ + "0" + ] + }, + { + "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": [ + "0" + ] + }, + { + "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", + "0" + ] + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json new file mode 100644 index 0000000..5b6fa07 --- /dev/null +++ b/tests/fixtures/golden/air_purifier.json @@ -0,0 +1,25 @@ +{ + "state_keys": [ + "alarm_code", + "blooming_level", + "clean_level", + "device_active", + "diagnosis_status", + "display_light", + "dust", + "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", + "fan_direction", + "fan_speed_level", + "filter_progress", + "fine_dust", + "odor", + "operating_mode", + "power_energy_kwh", + "power_switch", + "power_watts", + "super_fine_dust" + ] +} diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py new file mode 100644 index 0000000..fcf95f6 --- /dev/null +++ b/tests/test_air_purifier_capabilities.py @@ -0,0 +1,107 @@ +"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56).""" +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.capabilities import air_purifier +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + + +def _purifier(): + resources = _load_device('air_purifier') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _state(): + reg, resources = _purifier() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_model_resolves_to_air_purifier_registry(): + reg, _ = _purifier() + assert reg is not None + assert reg.name == 'air_purifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #56 dump binds or is ignored -- clears the + coverage-gap repair.""" + reg, resources = _purifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust', + 'odor', 'clean_level', 'filter_progress', 'device_active', + 'diagnosis_status', 'fan_speed_level', 'fan_direction', + 'display_light', 'operating_mode', 'blooming_level', + ): + assert key in state, key + + +def test_air_quality_sensor_values(): + """Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each + items[] entry's value list (the raw measurement, per common.sensor_item_value).""" + state = _state() + assert state['dust'] == 11 + assert state['fine_dust'] == 9 + assert state['super_fine_dust'] == 5 + assert state['odor'] == 0 + assert state['clean_level'] == 0 + + +def test_filter_progress_reads_named_consumable_item(): + assert _state()['filter_progress'] == 100 + + +def test_diagnosis_reuses_dishwasher_capability(): + """/diagnosis/vs/0 has the identical field/write contract as + dishwasher.DIAGNOSIS, so the by_type registry reuses it directly.""" + from custom_components.localthings.registry.capabilities import dishwasher + reg, _ = _purifier() + assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0'] + + +def test_light_switch_write_contract(): + """The display-light switch RMW-replaces only the 'Light_*' entry in the + packed /mode/vs/0 options list, preserving the other flags.""" + desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light') + rep = {'x.com.samsung.da.options': [ + 'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282', + ]} + assert desc.rep_fn(rep) is True + assert desc.write_fn('Off', rep) == ( + ['mode', 'vs', '0'], + {'x.com.samsung.da.options': [ + 'Comode_Off', 'Blooming_0', 'OptionCode_60282', 'Light_Off', + ]}, + ) + + +def test_mode_tokens_are_read_only_diagnostics(): + """Comode_/Blooming_ tokens surface as raw diagnostic sensors rather than + a select/control -- their valid value ranges aren't confirmed yet (see + the air_purifier.py module docstring and the issue #56 follow-up).""" + operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode') + blooming = next(e for e in air_purifier.MODE.entities if e.key == 'blooming_level') + rep = {'x.com.samsung.da.options': ['Comode_Off', 'Blooming_6']} + assert operating_mode.rep_fn(rep) == 'Off' + assert blooming.rep_fn(rep) == '6' + assert not hasattr(operating_mode, 'write_fn') + + +def test_airflow_vs_fallback_only_binds_without_generic(): + """/airflow/vs/0 is a match_fn fallback -- it must not bind when the + OCF-standard /airflow/0 is also present (both are on every dump seen).""" + assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn( + {}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}}, + ) is False + assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a4cca9b..13ad978 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -244,6 +244,20 @@ def test_registry_reproduces_golden_state_keys_for_range(): ) +def test_registry_reproduces_golden_state_keys_for_air_purifier(): + """ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via + the '_TVTL_' modelNum token fallback in for_device_by_model.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier') + golden = json.loads((GOLDEN / 'air_purifier.json').read_text()) + state_keys = _new_state_keys('air_purifier', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = {