From 42a80fffe6a7f8dceb02eae083fa7e828dd645e0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 22:36:47 +0000 Subject: [PATCH] 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. --- .../registry/by_type/air_purifier.py | 12 +- .../registry/capabilities/air_purifier.py | 125 ++++++++---------- .../registry/capabilities/common.py | 7 + .../registry/capabilities/range_hood.py | 13 +- tests/test_air_purifier_capabilities.py | 5 +- 5 files changed, 69 insertions(+), 93 deletions(-) diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 1dee48b..1042455 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -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, ]), ) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 65bac1a..2150a51 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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- + '_' 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'), +] diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 31b45ac..41adade 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -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 diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index b397160..48e2873 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -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, ), ), ) diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index fcf95f6..fb39425 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -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', ]}, )