Simplify air purifier support by reusing existing helpers

Rewires /mode/vs/0's packed-options parsing (display_light/operating_mode/
blooming_level) onto laundry.py's existing option_value/replace_in_options/
bool_option_exists/bool_option_value instead of hand-rolled reimplementations,
hoists the duplicated int-conversion helper into common.py (shared by
range_hood.py too), collapses the five near-identical AIR_QUALITY sensors
into a table-driven loop, extracts the /consumable/vs/0 item lookup into a
named helper, and moves the humidity ignore list into capabilities/
air_purifier.py's own COVERAGE list to match the airconditioner/range_hood
convention of keeping by_type files as pure composition.

No behavior change; golden state keys and all existing tests are unaffected.
This commit is contained in:
Marc Billow
2026-07-23 22:36:47 +00:00
parent b121d24966
commit 42a80fffe6
5 changed files with 69 additions and 93 deletions
@@ -3,20 +3,10 @@
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([
@@ -30,6 +20,6 @@ REGISTRY = DeviceRegistry(
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
*_HUMIDITY_EMPTY,
*air_purifier.COVERAGE,
]),
)
@@ -18,11 +18,12 @@ than modeled as real controls, per the "don't guess" rule:
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-
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW
contract laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this
family's own href). Of the tokens seen:
Light_On / Light_Off -- read as a plain on/off flag; MODE 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
@@ -36,44 +37,41 @@ than modeled as real controls, per the "don't guess" rule:
"""
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
from .common import int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, replace_in_options
_AIR_QUALITY_SENSORS = (
('dust', 'Dust', 'mdi:blur', 'Dust'),
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'Odor', 'mdi:scent', 'Odor'),
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
)
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')),
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
# 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).
def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
return None
# 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',
@@ -81,11 +79,8 @@ FILTER = Capability(
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))),
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
),
)
@@ -123,7 +118,7 @@ AIRFLOW_VS_FALLBACK = Capability(
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),
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'),
@@ -131,30 +126,11 @@ AIRFLOW_VS_FALLBACK = Capability(
)
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}
opts = list(rep.get('x.com.samsung.da.options') or [])
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'Light', payload),
}
MODE = Capability(
@@ -163,17 +139,26 @@ MODE = Capability(
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_'),
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('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_')),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('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_')),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Blooming'),
exists_fn=bool_option_exists('Blooming')),
),
)
# /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.
COVERAGE = [
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
]
@@ -23,6 +23,13 @@ def _num(v):
return None
def int_or_none(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def clamp_power(v):
n = _num(v)
return 0.0 if (n is not None and n < 0) else n
@@ -17,14 +17,7 @@ from ..entities import (
SensorDesc,
SwitchDesc,
)
from .common import sensor_item_value
def _int_or_none(value):
try:
return int(value)
except (TypeError, ValueError):
return None
from .common import int_or_none, sensor_item_value
def _timestamp(value):
@@ -165,7 +158,7 @@ HOOD_FILTER = Capability(
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
value_fn=_int_or_none,
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
@@ -182,7 +175,7 @@ HOOD_FILTER = Capability(
icon='mdi:timer-outline',
entity_category='diagnostic',
enabled_default=False,
value_fn=_int_or_none,
value_fn=int_or_none,
),
),
)
+3 -2
View File
@@ -72,7 +72,8 @@ def test_diagnosis_reuses_dishwasher_capability():
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."""
packed /mode/vs/0 options list (via laundry.replace_in_options), leaving
the other flags and the list order untouched."""
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',
@@ -81,7 +82,7 @@ def test_light_switch_write_contract():
assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'OptionCode_60282', 'Light_Off',
'Comode_Off', 'Blooming_0', 'Light_Off', 'OptionCode_60282',
]},
)