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
2026-07-28 00:18:38 +00:00
parent c19800ac00
commit e9281aaead
8 changed files with 387 additions and 11 deletions
+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',
),
),
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooking_mode",
"cycle_active",
"door_open",
"energy_kwh",
"fan",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"operation_time_minutes",
"power_level",
"progress_percentage",
"sound"
]
}
+201
View File
@@ -0,0 +1,201 @@
{
"device0": [
{
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"flashingProgress": "",
"otnStatus": "None",
"otnList": [
{
"type": "WIFI",
"modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
"versions": ["40241114"],
"visVersion": "241114"
},
{
"type": "Micom",
"modelId": "07424047334140473241",
"versions": ["24071000", "23120700"],
"visVersion": "240710"
},
{
"type": "Micom",
"modelId": "074240475443FFFFFFFF",
"versions": ["23120800", "FFFFFFFF"],
"visVersion": "231208"
}
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On",
"rt": ["x.com.samsung.da.connected"],
"if": ["oic.if.baseline", "oic.if.s"]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
],
"rt": ["x.com.samsung.da.doors"],
"if": ["oic.if.baseline", "oic.if.s"]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000",
"x.com.samsung.da.description": "ME7500D-/AA1",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "24111400",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04733A24071000, 04732A23120700",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04754C23120800",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": ["errCode", "dump"],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "KM5",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"],
"x.com.samsung.da.modes": ["NoOperation"],
"x.com.samsung.da.options": [
"DeviceType_ME7500D-/AA1",
"TimeAutoSync_On",
"weight_LBS",
"TimeSystem_12",
"Sound_On",
"RemindBeep_On",
"FilterRemind_Off",
"Lamp_Off"
],
"x.com.samsung.da.defaultMode": "MicroWave",
"rt": ["x.com.samsung.da.mode"],
"if": ["oic.if.baseline", "oic.if.a"]
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "0",
"rt": ["x.com.samsung.da.operation"],
"if": ["oic.if.baseline", "oic.if.a"]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": ["x.com.samsung.da.alarms"],
"if": ["oic.if.baseline", "oic.if.s"],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-27T20:03:10"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.recipe": "00000000000000",
"x.com.samsung.da.powerLevel": "0",
"rt": ["x.com.samsung.da.oven"],
"if": ["oic.if.baseline", "oic.if.s"]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/New_York",
"offset": "-04:00",
"DST": "ON"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "3400",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/hood/fanspeed/vs/0",
"rep": {
"x.com.samsung.da.hood.fanSpeed": "0",
"x.com.samsung.da.hood.supportedFanSpeed": ["0", "1", "2", "3", "4"],
"x.com.samsung.da.hood.settableMaxFanSpeed": "4",
"x.com.samsung.da.hood.settableMinFanSpeed": "0"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+24
View File
@@ -589,6 +589,30 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
)
def test_registry_reproduces_golden_state_keys_for_microwave_me7500d():
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
#137/#142) -- the same microwave registry as MW7300B above, but this
board also reports the built-in vent fan's `/hood/fanspeed/vs/0`
resource, previously unbound. Bound via range_hood.HOOD_FAN (reused
directly, same resource shape a standalone range hood reports); unlike
a standalone hood this board has no sibling `/power/0` or
`/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats
fan speed 0 as the off state instead of writing a separate power
resource -- see its `_has_separate_power` check. This board also has
no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation`
field, unlike MW7300B, so `setpoint`/`current_temp_c` and
`automatic_operation` are correctly absent here."""
from tests.conftest import _load_device
resources = _load_device('microwave_me7500d')
golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text())
state_keys = _new_state_keys('microwave_me7500d', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air():
"""TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion
'7.0 Air purifier' and resolves via for_device() onto the existing
+16
View File
@@ -23,6 +23,22 @@ def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
assert unbound == []
def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
"""Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in
vent fan) was previously unbound on this family."""
from tests.conftest import _load_device
resources = _load_device('microwave_me7500d')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None
assert reg.name == 'microwave'
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
# ---------------------------------------------------------------------------
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------
+71 -4
View File
@@ -1,7 +1,7 @@
"""HA fan-entity mapping tests for the range hood."""
from custom_components.localthings.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import range_hood
from custom_components.localthings.registry.by_type import microwave, range_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
@@ -23,11 +23,11 @@ class _FakeCoordinator:
self.commands.append((bound, payload))
def _entity(resources, coordinator=None):
def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
bound = discover(
resources,
range_hood.REGISTRY.capabilities,
range_hood.REGISTRY.pattern_capabilities,
registry.capabilities,
registry.pattern_capabilities,
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(
@@ -35,6 +35,10 @@ def _entity(resources, coordinator=None):
)
def _microwave_entity(resources, coordinator=None):
return _entity(resources, coordinator, registry=microwave.REGISTRY)
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
entity = _entity(_load_device('range_hood'))
assert entity.is_on is False
@@ -86,3 +90,66 @@ async def test_power_write_falls_back_to_vendor_resource():
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
# ---------------------------------------------------------------------------
# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
# itself the off state.
# ---------------------------------------------------------------------------
def test_microwave_vent_fan_has_no_separate_power_resource():
resources = _load_device('microwave_me7500d')
assert '/power/0' not in resources
assert '/power/vs/0' not in resources
entity = _microwave_entity(resources)
assert entity._has_separate_power() is False
def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
"""supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
standalone hood's codes, which never include an off entry."""
resources = _load_device('microwave_me7500d')
entity = _microwave_entity(resources)
assert entity.is_on is False
assert entity.percentage == 0
assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
def test_microwave_vent_fan_nonzero_speed_reads_as_on():
resources = _load_device('microwave_me7500d')
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
entity = _microwave_entity(resources)
assert entity.is_on is True
assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
resources = _load_device('microwave_me7500d')
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('speed', '0')
async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
resources = _load_device('microwave_me7500d')
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ('speed', '1')
async def test_microwave_vent_fan_set_percentage_writes_speed_only():
resources = _load_device('microwave_me7500d')
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_set_percentage(100)
assert coordinator.commands == [(entity._bound, ('speed', '4'))]