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:
Marc Billow
2026-07-28 02:10:01 +00:00
parent 265d94eded
commit cc3ce12aa4
7 changed files with 212 additions and 32 deletions
+47 -22
View File
@@ -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'),