diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 3727d9c..f21905f 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -63,6 +63,12 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): # field does), so every advertised code is an active ordered speed. return self._all_speed_codes() + def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: + """Target whichever power resource this hood actually exposes.""" + resources = self.coordinator.last_resources + target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF + return 'power', enabled, target + @property def is_on(self) -> bool: rep = self._rep(POWER_HREF) @@ -90,12 +96,16 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: - await self.coordinator.async_send_command(self._bound, ('power', True)) + await self.coordinator.async_send_command( + self._bound, self._power_payload(True), + ) if percentage is not None: await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: - await self.coordinator.async_send_command(self._bound, ('power', False)) + await self.coordinator.async_send_command( + self._bound, self._power_payload(False), + ) async def async_set_percentage(self, percentage: int) -> None: if percentage <= 0: @@ -105,6 +115,8 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): if not codes: return if not self.is_on: - await self.coordinator.async_send_command(self._bound, ('power', True)) + await self.coordinator.async_send_command( + self._bound, self._power_payload(True), + ) code = percentage_to_ordered_list_item(codes, percentage) await self.coordinator.async_send_command(self._bound, ('speed', code)) diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py index 4baed74..386394c 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -58,20 +58,23 @@ COOKTOP_POWER = Capability( href='/power/vs/0', poll_tier='hot', entities=( - SensorDesc( + BinarySensorDesc( key='power_state', field='x.com.samsung.da.power', name='Power state', + device_class='power', icon='mdi:stove', + value_fn=lambda value: str(value).lower() == 'on', ), ), ) -# NA9300K exposes physical slots 0, 1, 3, 4, and 5. Preserve Samsung's slot -# numbers instead of guessing at front/back/left/right placement. A user can -# safely identify and rename each entity by briefly lighting one burner. -_NA9300K_OPERATION_SLOTS = (0, 1, 3, 4, 5) +# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a +# generous static superset so variants with other layouts are not silently +# omitted; exists_fn hides every slot the live options array does not report. +# Replace this bound with data-driven entity generation when #31 lands. +_SUPPORTED_OPERATION_SLOTS = tuple(range(8)) COOKTOP_MODE = Capability( href='/mode/vs/0', @@ -101,7 +104,7 @@ COOKTOP_MODE = Capability( ) is not None ), ) - for slot in _NA9300K_OPERATION_SLOTS + for slot in _SUPPORTED_OPERATION_SLOTS ), SensorDesc( key='main_timer_state', diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 698c39a..8b0d96f 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -46,7 +46,11 @@ def _item_value(items, sensor_type, index=0): def _active_alarm_codes(items): - """Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.""" + """Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row. + + Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in + its live representation, so this family-specific helper also checks state. + """ codes = [] for item in items or (): if not isinstance(item, dict): @@ -76,9 +80,16 @@ HOOD_ALARMS = Capability( def _hood_fan_write(payload, rep, href=None): - kind, value = payload + kind, value, *args = payload if kind == 'power': - return ['power', '0'], {'value': bool(value)} + power_href = args[0] if args else '/power/0' + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + if power_href == '/power/vs/0': + return ['power', 'vs', '0'], { + 'x.com.samsung.da.power': 'On' if value else 'Off', + } + return None if kind == 'speed': value = str(value) supported = [ diff --git a/tests/test_cooktop_capabilities.py b/tests/test_cooktop_capabilities.py index c240141..35b3d99 100644 --- a/tests/test_cooktop_capabilities.py +++ b/tests/test_cooktop_capabilities.py @@ -21,7 +21,7 @@ def _state(resources): def test_real_cooktop_fixture_has_expected_idle_state(): state = _state(_load_device('cooktop')) - assert state['power_state'] == 'On' + assert state['power_state'] is True assert state['any_burner_active'] is False assert state['burner_0_state'] == 'Ready' assert state['burner_5_state'] == 'Ready' @@ -59,10 +59,19 @@ def test_cooktop_profile_has_no_write_functions(): for desc in descriptions) -def test_known_na9300k_slots_are_present(): +def test_static_slot_superset_has_headroom_for_other_layouts(): keys = {desc.key for desc in COOKTOP_MODE.entities} - assert { - 'burner_0_state', 'burner_1_state', 'burner_3_state', - 'burner_4_state', 'burner_5_state', - } <= keys + assert {f'burner_{slot}_state' for slot in range(8)} <= keys + + +def test_variant_slot_outside_na9300k_layout_is_discovered(): + resources = _load_device('cooktop') + resources['/mode/vs/0']['x.com.samsung.da.options'].append( + 'OperationState7_Ready', + ) + + state = _state(resources) + + assert state['burner_7_state'] == 'Ready' + assert 'burner_2_state' not in state diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py index aceb68e..3892036 100644 --- a/tests/test_range_hood_capabilities.py +++ b/tests/test_range_hood_capabilities.py @@ -84,6 +84,13 @@ def test_fan_write_contract(): assert desc.write_fn(('power', False), rep) == ( ['power', '0'], {'value': False}, ) + assert desc.write_fn(('power', True, '/power/vs/0'), rep) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}, + ) + assert desc.write_fn(('power', False, '/power/vs/0'), rep) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'}, + ) + assert desc.write_fn(('power', True, '/not-a-power-resource'), rep) is None assert desc.write_fn(('speed', '16'), rep) == ( ['hood', 'fanspeed', 'vs', '0'], {'x.com.samsung.da.hood.fanSpeed': '16'}, diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index c26308c..3f52d78 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -14,19 +14,25 @@ class _FakeCoordinator: def __init__(self, resources): self.last_resources = resources + self.commands = [] def resource(self, href): return self.last_resources.get(href, {}) + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) -def _entity(resources): + +def _entity(resources, coordinator=None): bound = discover( resources, range_hood.REGISTRY.capabilities, range_hood.REGISTRY.pattern_capabilities, ) fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc)) - return LocalThingsRangeHoodFan(_FakeCoordinator(resources), fan_bound) + return LocalThingsRangeHoodFan( + coordinator or _FakeCoordinator(resources), fan_bound, + ) def test_power_off_maps_to_zero_percent_and_four_retained_speeds(): @@ -59,3 +65,24 @@ def test_active_codes_map_to_ordered_percentages(): 'x.com.samsung.da.hood.fanSpeed' ] = '17' assert _entity(resources).percentage == 100 + + +async def test_power_write_prefers_standard_resource_when_both_exist(): + resources = _load_device('range_hood') + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands[-1][1] == ('power', True, '/power/0') + + +async def test_power_write_falls_back_to_vendor_resource(): + resources = _load_device('range_hood') + resources.pop('/power/0') + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')