From 4e1eb7d03d8838a4c767009660a35c515725642a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:08:32 +0000 Subject: [PATCH] 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 = {