Fix range hood fan power targeting and kimchi mode write validation
- _speed_zero_is_off now keys off the hood resource's own settableMinFanSpeed/supportedFanSpeed fields instead of asking whether the device has any power resource at all, so a combi appliance's cavity /power/0 can no longer be toggled off by turning off just the vent fan. - async_turn_on() no longer resets an already-running fan to its lowest speed when called without a percentage. - _has_separate_power() reads through the O(1) resource cache instead of copying the full resource snapshot on every property access. - _kimchi_mode_write rejects values the compartment didn't advertise in supportMode instead of writing them blind. - kimchi_ripening_status no longer lowercases its value, since it has no enum catalog entry to translate the lowercased token back through. - Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0 aggregate fallback on the one fixture that reports both. Adds regression tests for the combi-appliance power targeting, the already-on turn_on no-op, kimchi mode write validation, and a kimchi select display/write casing round-trip; a translation-coverage guard for kimchi_zone_mode codes mirroring the existing AC preset one.
This commit is contained in:
@@ -37,6 +37,8 @@ POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
||||
_OFF_SPEED_CODE = '0'
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
@@ -65,12 +67,20 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources.
|
||||
|
||||
Some boards that reuse this capability (built-in microwave vent fans,
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0` resource
|
||||
at all -- fan speed 0 is itself the off state there, with no separate
|
||||
power toggle to write. `_has_separate_power` detects that shape and
|
||||
switches every method below to drive off/on purely through the
|
||||
fanSpeed field, including '0' in the ordered speed codes as the off
|
||||
step instead of assuming every advertised code is an active speed.
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||
resource at all -- fan speed 0 is itself the off state there, with no
|
||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
||||
shape from the hood resource's own settableMinFanSpeed/
|
||||
supportedFanSpeed fields and switches every method below to drive
|
||||
off/on purely through the fanSpeed field, including '0' in the
|
||||
ordered speed codes as the off step instead of assuming every
|
||||
advertised code is an active speed.
|
||||
|
||||
This is deliberately not the same question as `_has_separate_power`,
|
||||
which only proves *some* power resource exists on the device -- on a
|
||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
||||
belong to the cavity, not the vent fan, and toggling it from here
|
||||
would turn off the whole appliance instead of just the fan.
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
@@ -88,8 +98,19 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _has_separate_power(self) -> bool:
|
||||
resources = self.coordinator.last_resources
|
||||
return POWER_HREF in resources or POWER_VS_HREF in resources
|
||||
return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
|
||||
|
||||
def _speed_zero_is_off(self) -> bool:
|
||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||
separate power resource to toggle. The board says so directly:
|
||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
||||
standalone hood's codes start at 14 and it carries a real /power
|
||||
resource instead, so this is False there."""
|
||||
rep = self._rep(self._bound.href)
|
||||
return (
|
||||
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
|
||||
or _OFF_SPEED_CODE in self._all_speed_codes()
|
||||
)
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
@@ -97,14 +118,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
codes = self._all_speed_codes()
|
||||
if self._has_separate_power():
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
return codes
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != '0']
|
||||
if self._speed_zero_is_off():
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
return codes
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
@@ -114,9 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
if not self._has_separate_power():
|
||||
if self._speed_zero_is_off():
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
||||
return current not in ('', '0')
|
||||
return current not in ('', _OFF_SPEED_CODE)
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
@@ -142,10 +163,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if not self._has_separate_power():
|
||||
if self._speed_zero_is_off():
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
if self.is_on:
|
||||
# Already running: no percentage given means "just turn on",
|
||||
# not "reset to the lowest speed".
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if codes:
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
||||
@@ -157,8 +182,8 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
if not self._has_separate_power():
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', '0'))
|
||||
if self._speed_zero_is_off():
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
@@ -171,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
return
|
||||
if self._has_separate_power() and not self.is_on:
|
||||
if not self._speed_zero_is_off() and not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ shape a standalone range hood reports it in -- reused directly from
|
||||
range_hood.py rather than duplicated. Unlike a standalone hood, this dump
|
||||
has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
|
||||
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
||||
case (see its `_has_separate_power` check).
|
||||
case (see its `_speed_zero_is_off` check).
|
||||
"""
|
||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
@@ -675,7 +675,7 @@ DOOR_GENERIC = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _kimchi_mode_write(p, rep, href=None):
|
||||
if not href:
|
||||
if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
|
||||
return None
|
||||
return [s for s in href.strip('/').split('/') if s], {
|
||||
'x.com.samsung.da.currentMode': p,
|
||||
@@ -697,8 +697,7 @@ KIMCHI_ZONE = Capability(
|
||||
SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
|
||||
use_instance_name=True, icon='mdi:progress-clock',
|
||||
translation_key='kimchi_ripening_status',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
|
||||
use_instance_name=True, icon='mdi:timer-sand',
|
||||
translation_key='kimchi_ripening_remaining',
|
||||
@@ -721,6 +720,13 @@ KIMCHI_DOOR_GENERIC = Capability(
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
||||
# that doesn't share this compartment's "top" instance numbering --
|
||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
||||
# switch reported twice.
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door'),
|
||||
|
||||
Reference in New Issue
Block a user