Bind microwave built-in vent fan's /hood/fanspeed/vs/0 (issues #137, #142)

Combi microwave units report their vent fan on the same resource shape a
standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the
microwave registry. Unlike a standalone hood, this board has no sibling
/power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls
back to treating fan speed 0 as the off state when no separate power
resource is present. Also gate HOOD_FAN's automatic_operation sensor on
field presence, since this board doesn't report it.
This commit is contained in:
Marc Billow committed 2026-07-28 00:18:38 +00:00
1 parent c19800ac00
commit e9281aaead
8 files changed
+387 -11

No files matched your search

+38 -6
View File
@@ -62,7 +62,16 @@ async def async_setup_entry(
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""A hood fan combining sibling power and fan-speed resources."""
"""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.
"""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
@@ -78,15 +87,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _rep(self, href: str) -> dict:
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
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
def _active_speed_codes(self) -> list[str]:
# 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 self._all_speed_codes()
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']
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
@@ -96,6 +114,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
@property
def is_on(self) -> bool:
if not self._has_separate_power():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
return current not in ('', '0')
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
@@ -121,6 +142,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
if not self._has_separate_power():
if percentage is not None:
await self.async_set_percentage(percentage)
return
codes = self._active_speed_codes()
if codes:
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
@@ -128,6 +157,9 @@ 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'))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
)
@@ -139,7 +171,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
codes = self._active_speed_codes()
if not codes:
return
if not self.is_on:
if self._has_separate_power() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
@@ -7,8 +7,16 @@ Cooking mode, setpoint, cavity power level, and lamp are genuinely
different for this family (different mode vocabulary, different setpoint
bounds, an extra powerLevel field, a differently-named lamp option) and are
defined fresh in capabilities/microwave.py -- see that module's docstring.
Some combi units (built-in over-the-range microwaves, issues #137/#142)
also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
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).
"""
from ..capabilities import common, ignored, microwave, oven
from ..capabilities import common, ignored, microwave, oven, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
@@ -24,5 +32,6 @@ REGISTRY = DeviceRegistry(
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]),
)
@@ -99,6 +99,11 @@ HOOD_FAN = Capability(
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
# Absent on the microwave family's built-in vent fan (issue
# #137) -- this board has no auto-ventilation mode, unlike the
# standalone range hood this capability was written for.
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
),
),