Address cooktop and range hood review
This commit is contained in:
1 parent
e7daa21931
commit
62caf769f2
6 files changed
+89
-20
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
@@ -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'},
|
||||
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user