Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Bottein 18f6f7228a Use command 2026-05-24 11:25:16 +02:00
Paul Bottein 21664b933a Migrate Novy Cooker Hood to new rf-protocols command API 2026-05-22 15:12:33 +02:00
7 changed files with 60 additions and 84 deletions
@@ -1,7 +0,0 @@
"""Command names for the Novy Cooker Hood RF codes."""
from typing import Final
COMMAND_LIGHT: Final = "light"
COMMAND_PLUS: Final = "plus"
COMMAND_MINUS: Final = "minus"
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
import voluptuous as vol
from homeassistant.components.radio_frequency import (
@@ -19,7 +19,6 @@ from homeassistant.const import CONF_CODE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .commands import COMMAND_LIGHT
from .const import (
CODE_MAX,
CODE_MIN,
@@ -128,10 +127,8 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Toggle the hood light on then off so it ends in its starting state."""
assert self._transmitter_entity_id is not None
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
try:
command = await get_codes_for_code(self._code).async_load_command(
COMMAND_LIGHT
)
await async_send_command(self.hass, self._transmitter_entity_id, command)
await asyncio.sleep(_TOGGLE_GAP)
await async_send_command(self.hass, self._transmitter_entity_id, command)
@@ -1,9 +1,11 @@
"""Fan platform for the Novy Cooker Hood (calibrated speed control)."""
import asyncio
import math
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from rf_protocols.commands.novy import NovyCookerHoodCommand
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
from homeassistant.components.radio_frequency import async_send_command
@@ -17,7 +19,6 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
from .commands import COMMAND_MINUS, COMMAND_PLUS
from .const import SPEED_COUNT
from .entity import NovyCookerHoodEntity
@@ -25,6 +26,9 @@ PARALLEL_UPDATES = 1
_SPEED_RANGE = (1, SPEED_COUNT)
# Novy hood expects at least 150ms between RF commands
_COMMAND_DELAY = 0.2
async def async_setup_entry(
hass: HomeAssistant,
@@ -49,7 +53,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the fan."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code: int = entry.data[CONF_CODE]
self._level = 0
self._attr_unique_id = entry.entry_id
@@ -103,18 +107,16 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed up by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
plus = await self._codes.async_load_command(COMMAND_PLUS)
for _ in range(steps):
await self._async_send(plus)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
await self._async_send_repeated(plus, steps)
self._level = min(SPEED_COUNT, self._level + steps)
self.async_write_ha_state()
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed down by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
minus = await self._codes.async_load_command(COMMAND_MINUS)
for _ in range(steps):
await self._async_send(minus)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
await self._async_send_repeated(minus, steps)
self._level = max(0, self._level - steps)
self.async_write_ha_state()
@@ -127,17 +129,25 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def _async_set_level(self, level: int) -> None:
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
minus = await self._codes.async_load_command(COMMAND_MINUS)
for _ in range(SPEED_COUNT):
await self._async_send(minus)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
await self._async_send_repeated(minus, SPEED_COUNT)
if level > 0:
plus = await self._codes.async_load_command(COMMAND_PLUS)
for _ in range(level):
await self._async_send(plus)
await asyncio.sleep(_COMMAND_DELAY)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
await self._async_send_repeated(plus, level)
self._level = level
self.async_write_ha_state()
async def _async_send(self, command: Any) -> None:
async def _async_send_repeated(
self, command: NovyCookerHoodCommand, count: int
) -> None:
"""Send the same RF command N times, pausing between presses."""
for i in range(count):
if i > 0:
await asyncio.sleep(_COMMAND_DELAY)
await self._async_send(command)
async def _async_send(self, command: NovyCookerHoodCommand) -> None:
"""Send a single RF command via the configured transmitter."""
await async_send_command(
self.hass, self._transmitter, command, context=self._context
@@ -2,7 +2,7 @@
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
@@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .commands import COMMAND_LIGHT
from .entity import NovyCookerHoodEntity
PARALLEL_UPDATES = 1
@@ -37,7 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the light."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code = entry.data[CONF_CODE]
self._attr_unique_id = entry.entry_id
async def async_added_to_hass(self) -> None:
@@ -48,19 +47,19 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self._codes.async_load_command(name)
async def _async_send_light(self) -> None:
"""Send the light toggle command via the configured transmitter."""
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
+6 -27
View File
@@ -1,10 +1,9 @@
"""Common fixtures for the Novy Cooker Hood tests."""
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
from rf_protocols.loader import CodeCollection
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
from homeassistant.const import CONF_CODE
@@ -12,36 +11,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.radio_frequency.common import (
MockRadioFrequencyCommand,
MockRadioFrequencyEntity,
)
from tests.components.radio_frequency.common import MockRadioFrequencyEntity
TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter"
@pytest.fixture(autouse=True)
def mock_get_codes() -> Iterator[MagicMock]:
"""Patch the bundled-codes loader so tests don't hit the filesystem."""
fake_collection = MagicMock(spec=CodeCollection)
fake_collection.async_load_command = AsyncMock(
side_effect=lambda name: MockRadioFrequencyCommand()
)
with (
patch(
"homeassistant.components.novy_cooker_hood.light.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.fan.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.config_flow.get_codes_for_code",
return_value=fake_collection,
),
):
yield fake_collection
def mock_command_delay() -> Iterator[None]:
"""Drop the inter-command delay so tests don't spend real time waiting."""
with patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", 0):
yield
@pytest.fixture
@@ -1,11 +1,11 @@
"""Test the Novy Hood config flow."""
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
from homeassistant.config_entries import SOURCE_USER
@@ -49,7 +49,6 @@ async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict:
async def test_user_flow_test_then_finish(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -58,8 +57,10 @@ async def test_user_flow_test_then_finish(
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
assert len(mock_rf_entity.send_command_calls) == 2
sent = mock_rf_entity.send_command_calls[0].command
assert sent.key == NovyCookerHoodButton.LIGHT.code
assert sent.channel == 3
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -77,7 +78,6 @@ async def test_user_flow_test_then_finish(
async def test_user_flow_retry_picks_different_code(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -99,9 +99,13 @@ async def test_user_flow_retry_picks_different_code(
},
)
assert result["type"] is FlowResultType.MENU
# One load per test x two tests; two sends per test x two tests.
assert mock_get_codes.async_load_command.await_count == 2
assert len(mock_rf_entity.send_command_calls) == 4
assert [c.command.channel for c in mock_rf_entity.send_command_calls] == [
1,
1,
7,
7,
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -127,7 +131,6 @@ async def test_user_flow_test_transmit_failure(
async def test_recover_after_transmit_failure(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""The user can Retry from test_failed and complete the flow."""
@@ -183,7 +186,6 @@ async def test_unique_id_already_configured(
async def test_same_transmitter_different_code_is_allowed(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_config_entry: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -205,7 +207,6 @@ async def test_same_transmitter_different_code_is_allowed(
async def test_reconfigure_updates_entry(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -224,7 +225,9 @@ async def test_reconfigure_updates_entry(
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
sent = mock_rf_entity.send_command_calls[-1].command
assert sent.key == NovyCookerHoodButton.LIGHT.code
assert sent.channel == 4
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -239,7 +242,6 @@ async def test_reconfigure_updates_entry(
async def test_reconfigure_frees_old_unique_id(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -295,7 +297,6 @@ async def test_reconfigure_aborts_on_collision(
async def test_reconfigure_retry_returns_to_picker(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -326,7 +327,6 @@ async def test_no_transmitters(hass: HomeAssistant) -> None:
async def test_recover_after_no_transmitters(
hass: HomeAssistant,
mock_get_codes: MagicMock,
) -> None:
"""User can re-init the flow after the radio_frequency integration loads."""
result = await hass.config_entries.flow.async_init(
@@ -1,13 +1,12 @@
"""Tests for the Novy Hood light platform."""
from unittest.mock import MagicMock, call
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@@ -28,7 +27,6 @@ ENTITY_ID = "light.novy_cooker_hood_light"
async def test_turn_on_and_off_send_light_once_each(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
init_novy_cooker_hood: MockConfigEntry,
) -> None:
@@ -66,11 +64,11 @@ async def test_turn_on_and_off_send_light_once_each(
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
assert mock_get_codes.async_load_command.await_args_list == [
call(COMMAND_LIGHT),
call(COMMAND_LIGHT),
]
assert len(mock_rf_entity.send_command_calls) == 2
assert [c.command.key for c in mock_rf_entity.send_command_calls] == [
NovyCookerHoodButton.LIGHT.code,
NovyCookerHoodButton.LIGHT.code,
]
async def test_restore_state(