Fix airflow fan power-href preference and unique_id collision risk
Opus review of the previous commit caught two real bugs. The airflow fan's power writes preferred /power/vs/0, copied from the TP1X fan class -- but that order is only harmless there because TP1X never reports /power/0 at all. This family's dumps carry both hrefs, and the power_switch entity is unconditionally bound to /power/0 when present, so the fan was writing to a different resource than power_switch reads/writes, leaving the two entities disagreeing until the next poll. Flipped to prefer /power/0, matching the range hood's fan and common.POWER_GENERIC. Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity unique_ids are derived from key alone, not href, so it collided with air_purifier.FAN's own 'fan' key on the (currently unobserved, but unenforced) possibility of a board reporting both. Added a platform-level test file covering the power-href preference and percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness.
This commit is contained in:
6 files changed
+148
-24
No files matched your search
@@ -253,17 +253,26 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Same power-href fallback as LocalThingsAirPurifierFan above."""
|
||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||
harmless for the TP1X board because it never reports /power/0 at
|
||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
||||
(the power_switch entity) is unconditionally bound to /power/0
|
||||
whenever it's present, so writing here to /power/vs/0 first would
|
||||
leave power_switch and this fan reading/writing two different
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
resources = self.coordinator.last_resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_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'))
|
||||
power = self._rep(POWER_HREF)
|
||||
if 'value' in power:
|
||||
return bool(power.get('value'))
|
||||
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
|
||||
@@ -136,16 +136,6 @@ DEVICE_ACTIVE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# 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':
|
||||
@@ -159,11 +149,29 @@ def _airflow_fan_write(payload, rep, href=None):
|
||||
return None
|
||||
|
||||
|
||||
# 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.
|
||||
#
|
||||
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
|
||||
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
|
||||
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
|
||||
# only *empirically* mutually exclusive (every dump seen has one board
|
||||
# generation's shape or the other, never both), not architecturally
|
||||
# enforced the way same-href caps are by _build()'s match_fn check -- a
|
||||
# same key would collide if a future board ever reported both.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href='/airflow/0',
|
||||
href=HREF_AIRFLOW,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
FanDesc(key='fan', field='speed', write_fn=_airflow_fan_write),
|
||||
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
|
||||
Reference in new issue
Block a user