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.
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user