diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9b7ebed..dbc73c1 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -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), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index c038e49..cbe85fa 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -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, ]), ) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 59cebc9..f3b4eca 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -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', ), ), diff --git a/tests/fixtures/golden/microwave_me7500d.json b/tests/fixtures/golden/microwave_me7500d.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d.json @@ -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" + ] +} diff --git a/tests/fixtures/microwave_me7500d_device.json b/tests/fixtures/microwave_me7500d_device.json new file mode 100644 index 0000000..d37c591 --- /dev/null +++ b/tests/fixtures/microwave_me7500d_device.json @@ -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" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..1c27880 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 45a7487..05f0b91 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 3f52d78..4da948b 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -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'))]