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.
This commit is contained in:
Marc Billow committed 2026-07-27 14:08:32 +00:00
1 parent e44fef7085
commit 4e1eb7d03d
9 files changed
+1002 -12

No files matched your search

+83 -6
View File
@@ -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
@@ -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,
]),
)
@@ -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'),
]
@@ -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"
},
@@ -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"
},
+466
View File
@@ -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"
}
}
]
}
+27
View File
@@ -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"
]
}
@@ -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
+21
View File
@@ -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 = {