From d4583b324055e84eac86f60273456100c65ab12c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 21:32:44 +0000 Subject: [PATCH] Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56) /airflow/0's speed was left read-only because the first round of diagnostics wasn't conclusive (0 for both Auto and High, 3 for Low/Medium and Sleep) -- likely because all five dumps were captured within about a minute of each other, faster than this integration's own poll cycle could settle each change. A second round, captured 60-90s apart per setting on two independent units, confirmed a clean monotonic 0-4 mapping across Auto/Sleep/Low/Medium/ High instead. Builds an ordered-speed fan off that confirmed range, the same SET_SPEED shape as the range hood's fan -- this board never self-reports a supportedModes-style label list, so there's no named-preset table to preserve, just percentage steps over the raw code. /airflow/vs/0's vendor speedLevel stays a read-only fallback since it was unreliable in that same second round. --- custom_components/localthings/fan.py | 93 ++++++++++++++++++- .../registry/capabilities/air_purifier.py | 59 +++++++++--- tests/fixtures/golden/air_purifier.json | 2 +- tests/test_air_purifier_capabilities.py | 26 +++++- 4 files changed, 160 insertions(+), 20 deletions(-) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 15ac111..693d45d 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -1,10 +1,15 @@ """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).""" +Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry +below since each needs different HA fan semantics: the range hood's fan +speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/ +Medium/High (issue #56) are both an ordered set of numeric levels +(SET_SPEED) -- the latter confirmed monotonic in capabilities/ +air_purifier.py's module docstring, with no named-mode list to preserve +since this board never self-reports one. The newer TP1X air-purifier +family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named +behaviors with no linear order (PRESET_MODE), reported directly by that +board's own supportedModes.""" from __future__ import annotations @@ -22,6 +27,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_AIRFLOW from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF from .registry.entities import FanDesc @@ -48,6 +54,8 @@ async def async_setup_entry( continue if bound.href == AIR_PURIFIER_FAN_HREF: entities.append(LocalThingsAirPurifierFan(coordinator, bound)) + elif bound.href == HREF_AIRFLOW: + entities.append(LocalThingsAirflowFan(coordinator, bound)) else: entities.append(LocalThingsRangeHoodFan(coordinator, bound)) async_add_entities(entities) @@ -214,3 +222,78 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): "%s: %r is not a valid preset mode (supported: %s)", self.entity_id, preset_mode, self.preset_modes, ) + + +_AIRFLOW_SPEED_FIELD = 'speed' +# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1, +# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered +# as plain strings, same as _all_speed_codes above, so +# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat +# it exactly like the range hood's numeric levels -- no named-preset table +# needed since this board never reports mode names to hang one off of. +_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4') + + +class LocalThingsAirflowFan(LocalThingsEntity, FanEntity): + """ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric + speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | 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 _power_payload(self, enabled: bool) -> tuple[str, bool, str]: + """Same power-href fallback as LocalThingsAirPurifierFan above.""" + resources = self.coordinator.last_resources + target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF + return 'power', enabled, target + + @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 speed_count(self) -> int: + return len(_AIRFLOW_SPEED_CODES) + + @property + def percentage(self) -> int | None: + if not self.is_on: + return 0 + current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, '')) + if current not in _AIRFLOW_SPEED_CODES: + return None + return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current) + + 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, self._power_payload(True)) + if percentage is not None: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + await self.coordinator.async_send_command(self._bound, self._power_payload(False)) + + async def async_set_percentage(self, percentage: int) -> None: + if percentage <= 0: + await self.async_turn_off() + return + if not self.is_on: + await self.coordinator.async_send_command(self._bound, self._power_payload(True)) + code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage) + await self.coordinator.async_send_command(self._bound, ('speed', int(code))) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5e867d6..be67cd8 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -39,14 +39,23 @@ set to Auto/Sleep/Low/Medium/High): automatic side effect of sleep mode, e.g. a display-dimming level, but that's still a guess). -/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real -fan-speed control: across the same five dumps it read 0 for both Auto *and* -High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any -selectable level, and the dumps were all captured within about three minutes -of each other (only one poll cycle apart at this integration's 30s summary -interval), so the values may not have settled after each change before the -diagnostics snapshot was taken. Exposed read-only pending a confirmed, -stable capture -- see the issue #56 discussion for what's needed. +/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up). +The first round of five dumps above wasn't conclusive -- it read 0 for both +Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five +were captured within about a minute of each other, faster than this +integration's own ~30s poll cycle could settle each change. A second round, +captured 60-90s apart per setting on two independent units, confirmed a +clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4. +AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4 +range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's +percentage steps straight onto the raw code, no named-preset table needed +(unlike the TP1X family's FAN, which exposes real named modes because its +board actually reports a supportedModes list to hang names off of). + +/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it +was unreliable on both units in that second round (Low/Medium collided on +one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below +stays a plain read-only diagnostic even after this change. """ from ..capability import Capability from ..entities import ( @@ -64,6 +73,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option # 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' +HREF_AIRFLOW = '/airflow/0' def _has_top_level_modes(rep, resources): @@ -126,21 +136,44 @@ DEVICE_ACTIVE = Capability( ), ) -# OCF-native / vendor pair for fan speed+direction -- see module docstring for -# why these are read-only for now. +# Confirmed via issue #56's second, properly-spaced round of diagnostics +# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is +# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now +# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same +# SET_SPEED shape as the range hood's) instead of a read-only sensor -- +# no named-preset table needed, since HA's percentage steps map onto the +# raw 0-4 code directly, the same way the range hood's numeric levels do. +# `direction` stays a plain diagnostic: every dump seen (both rounds, both +# units) reads 'Off' for it regardless of fan setting, so there's nothing +# confirmed to control there yet. +def _airflow_fan_write(payload, rep, href=None): + kind, value, *args = payload + if kind == 'power': + power_href = args[0] if args else '/power/vs/0' + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + if kind == 'speed': + return ['airflow', '0'], {'speed': int(value)} + return None + + AIRFLOW_GENERIC = Capability( href='/airflow/0', poll_tier='warm', entities=( - SensorDesc(key='fan_speed_level', field='speed', - icon='mdi:fan', - state_class='measurement', entity_category='diagnostic'), + FanDesc(key='fan', field='speed', write_fn=_airflow_fan_write), SensorDesc(key='fan_direction', field='direction', icon='mdi:rotate-3d-variant', entity_category='diagnostic'), ), ) +# Left exactly as a read-only fallback -- speedLevel is NOT the same +# confirmed-reliable field as /airflow/0's speed above (see module +# docstring): it collided Low/Medium on one unit and stuck at 0 throughout +# on the other in the same properly-spaced round. AIRFLOW_VS_FALLBACK = Capability( href='/airflow/vs/0', match_fn=lambda rep, resources: '/airflow/0' not in resources, diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json index 91ce610..cea09bb 100644 --- a/tests/fixtures/golden/air_purifier.json +++ b/tests/fixtures/golden/air_purifier.json @@ -10,8 +10,8 @@ "energy_last_month_kwh", "energy_saved_kwh", "energy_this_month_kwh", + "fan", "fan_direction", - "fan_speed_level", "filter_progress", "fine_dust", "odor", diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 7f483c8..5040673 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -41,7 +41,7 @@ def test_expected_entities_present(): 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', + 'diagnosis_status', 'fan', 'fan_direction', 'display_light', 'operating_mode', ): assert key in state, key @@ -114,3 +114,27 @@ def test_airflow_vs_fallback_only_binds_without_generic(): {}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}}, ) is False assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True + + +def test_airflow_fan_write_contract(): + """Confirmed via issue #56's second, properly-spaced diagnostics round + (two independent units, 60-90s apart per setting): /airflow/0's `speed` + is a clean, monotonic 0-4 code, so the write is a plain int passthrough + -- no named-preset table needed (see fan.py's LocalThingsAirflowFan).""" + fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'fan') + assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3}) + assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}, + ) + assert fan_desc.write_fn(('power', False, '/power/0'), {}) == ( + ['power', '0'], {'value': False}, + ) + + +def test_airflow_speed_reads_from_dump(): + """The fixture's /airflow/0 (power-off snapshot) reads speed=0, and + fan_direction stays a plain diagnostic (every dump seen reads 'Off' + regardless of fan setting).""" + state = _state() + assert state['fan'] == 0 + assert state['fan_direction'] == 'Off'