Address cooktop and range hood review

This commit is contained in:
themaanda committed 2026-07-22 21:54:01 -05:00
1 parent e7daa21931
commit 62caf769f2
6 files changed
+89 -20

No files matched your search

+15 -3
View File
@@ -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))
@@ -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',
@@ -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 = [
+15 -6
View File
@@ -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
+7
View File
@@ -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'},
+29 -2
View File
@@ -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')