diff --git a/homeassistant/components/novy_cooker_hood/__init__.py b/homeassistant/components/novy_cooker_hood/__init__.py index 550617c9bc91f6..4e21a91fb91cf1 100644 --- a/homeassistant/components/novy_cooker_hood/__init__.py +++ b/homeassistant/components/novy_cooker_hood/__init__.py @@ -6,7 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS: list[Platform] = [Platform.LIGHT] +PLATFORMS: list[Platform] = [Platform.FAN, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/novy_cooker_hood/commands.py b/homeassistant/components/novy_cooker_hood/commands.py new file mode 100644 index 00000000000000..976bb3ae0c9e43 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/commands.py @@ -0,0 +1,16 @@ +"""Helpers for loading Novy cooker-hood RF commands.""" + +from __future__ import annotations + +from typing import Final + +from rf_protocols import CodeCollection, get_codes + +COMMAND_LIGHT: Final = "light" +COMMAND_PLUS: Final = "plus" +COMMAND_MINUS: Final = "minus" + + +def get_codes_for_code(code: int) -> CodeCollection: + """Return the bundled `rf-protocols` collection for a Novy cooker-hood code.""" + return get_codes(f"novy/cooker_hood/code_{code}") diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py index f79b450d8b670c..d1dd5f0314823c 100644 --- a/homeassistant/components/novy_cooker_hood/config_flow.py +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -15,8 +15,17 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector -from .const import CODE_MAX, CODE_MIN, CONF_CODE, CONF_TRANSMITTER, DEFAULT_CODE, DOMAIN -from .light import COMMAND_LIGHT, FREQUENCY, MODULATION, get_codes_for_code +from .commands import COMMAND_LIGHT, get_codes_for_code +from .const import ( + CODE_MAX, + CODE_MIN, + CONF_CODE, + CONF_TRANSMITTER, + DEFAULT_CODE, + DOMAIN, + FREQUENCY, + MODULATION, +) _CODE_OPTIONS = [str(code) for code in range(CODE_MIN, CODE_MAX + 1)] _TOGGLE_GAP = 1.5 diff --git a/homeassistant/components/novy_cooker_hood/const.py b/homeassistant/components/novy_cooker_hood/const.py index 45b4b3342bc54c..0d4c06154f2edd 100644 --- a/homeassistant/components/novy_cooker_hood/const.py +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -4,6 +4,8 @@ from typing import Final +from rf_protocols import ModulationType + DOMAIN: Final = "novy_cooker_hood" CONF_TRANSMITTER: Final = "transmitter" @@ -12,3 +14,8 @@ CODE_MIN: Final = 1 CODE_MAX: Final = 10 DEFAULT_CODE: Final = 1 + +FREQUENCY: Final = 433_920_000 +MODULATION: Final = ModulationType.OOK + +SPEED_COUNT: Final = 4 diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py new file mode 100644 index 00000000000000..287ce19c88ddad --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,143 @@ +"""Fan platform for the Novy Cooker Hood (calibrated speed control).""" + +from __future__ import annotations + +import math +from typing import Any + +from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .commands import COMMAND_MINUS, COMMAND_PLUS, get_codes_for_code +from .const import CONF_CODE, SPEED_COUNT +from .entity import NovyCookerHoodEntity + +PARALLEL_UPDATES = 1 + +_SPEED_RANGE = (1, SPEED_COUNT) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood fan platform.""" + async_add_entities([NovyCookerHoodFan(config_entry)]) + + +class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): + """Calibration-based fan: each change resets to off then climbs to target.""" + + _attr_name = None + _attr_speed_count = SPEED_COUNT + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the fan.""" + super().__init__(entry) + self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._level = 0 + self._attr_unique_id = entry.entry_id + + @property + def is_on(self) -> bool: + """Return whether the fan is currently on.""" + return self._level > 0 + + @property + def percentage(self) -> int: + """Return the current speed as a percentage.""" + if self._level == 0: + return 0 + return ranged_value_to_percentage(_SPEED_RANGE, self._level) + + async def async_added_to_hass(self) -> None: + """Restore the last known speed level from the saved percentage.""" + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is None: + return + last_pct = last.attributes.get(ATTR_PERCENTAGE) + if isinstance(last_pct, (int, float)) and last_pct > 0: + self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on at the requested level (default = 1).""" + if percentage is None or percentage <= 0: + level = 1 + else: + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off by sending the calibration sequence to level 0.""" + await self._async_set_level(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed via calibration.""" + if percentage <= 0: + await self._async_set_level(0) + return + level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage)) + await self._async_set_level(level) + + 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) + 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) + self._level = max(0, self._level - steps) + self.async_write_ha_state() + + @staticmethod + def _steps_from_percentage(percentage_step: int | None) -> int: + """Convert a percentage step into a number of hardware level presses.""" + if percentage_step is None: + return 1 + return math.ceil(percentage_step * SPEED_COUNT / 100) + + 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) + if level > 0: + plus = await self._codes.async_load_command(COMMAND_PLUS) + for _ in range(level): + await self._async_send(plus) + self._level = level + self.async_write_ha_state() + + async def _async_send(self, command: Any) -> None: + """Send a single RF command via the configured transmitter.""" + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index b4d6b0455be238..9061a275458066 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -4,8 +4,6 @@ from typing import Any -from rf_protocols import CodeCollection, ModulationType, get_codes - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry @@ -14,20 +12,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .commands import COMMAND_LIGHT, get_codes_for_code from .const import CONF_CODE from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 -FREQUENCY = 433_920_000 -MODULATION = ModulationType.OOK -COMMAND_LIGHT = "light" - - -def get_codes_for_code(code: int) -> CodeCollection: - """Return the bundled `rf-protocols` collection for a Novy cooker hood code.""" - return get_codes(f"novy/cooker_hood/code_{code}") - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index cc600e554b31fb..b7f835e62d8500 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -33,7 +33,7 @@ def mock_get_codes() -> Iterator[MagicMock]: side_effect=lambda name: MockRadioFrequencyCommand() ) with patch( - "homeassistant.components.novy_cooker_hood.light.get_codes", + "homeassistant.components.novy_cooker_hood.commands.get_codes", return_value=fake_collection, ): yield fake_collection diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py index 77f1da0e8e54d3..c920c51ab2b5f7 100644 --- a/tests/components/novy_cooker_hood/test_config_flow.py +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -7,12 +7,12 @@ import pytest +from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT from homeassistant.components.novy_cooker_hood.const import ( CONF_CODE, CONF_TRANSMITTER, DOMAIN, ) -from homeassistant.components.novy_cooker_hood.light import COMMAND_LIGHT from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant diff --git a/tests/components/novy_cooker_hood/test_fan.py b/tests/components/novy_cooker_hood/test_fan.py new file mode 100644 index 00000000000000..ae63034c530639 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -0,0 +1,285 @@ +"""Tests for the Novy Hood fan platform.""" + +from __future__ import annotations + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + DOMAIN as FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import Context, HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.common import MockRadioFrequencyEntity + +ENTITY_ID = "fan.novy_cooker_hood" + + +async def test_turn_on_calibrates_to_level_1( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """Default turn_on sends 4 minus + 1 plus and lands at 25%.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 5 + assert all(c.context is context for c in mock_rf_entity.send_command_calls) + + +async def test_turn_on_with_percentage_calibrates_to_level( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """turn_on with percentage targets the matching level via calibration.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 6 + + +async def test_set_percentage_zero_turns_off( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """set_percentage(0) turns the fan off via the calibration sequence.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert len(mock_rf_entity.send_command_calls) == 4 + + +async def test_turn_off_sends_four_minuses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """turn_off sends 4 minus presses.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert len(mock_rf_entity.send_command_calls) == 4 + + +async def test_set_percentage_calibrates( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """set_percentage(75) sends 4 minus + 3 plus and lands at level 3.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 75}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 75 + assert len(mock_rf_entity.send_command_calls) == 7 + + +async def test_increase_speed_sends_single_plus( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """increase_speed sends one plus and bumps level by one (no recalibration).""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_increase_speed_clamps_at_max( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Pressing increase at level 4 still sends the RF press but clamps level.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 100})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 100 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_decrease_speed_sends_single_minus( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """decrease_speed sends one minus and drops level by one.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 50})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 25 + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_increase_speed_with_step_sends_n_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """increase_speed with percentage_step sends N plus presses (no recalibration).""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE_STEP: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 2 + + +async def test_decrease_speed_with_step_sends_n_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """decrease_speed with percentage_step sends N minus presses (no recalibration).""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 100})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE_STEP: 50}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert len(mock_rf_entity.send_command_calls) == 2 + + +async def test_decrease_speed_clamps_at_off( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """decrease_speed at level 0 still sends one minus but level stays at 0.""" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert len(mock_rf_entity.send_command_calls) == 1 + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """The fan restores its previous percentage without sending commands.""" + mock_restore_cache( + hass, [State(ENTITY_ID, STATE_ON, attributes={ATTR_PERCENTAGE: 50})] + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert mock_rf_entity.send_command_calls == [] diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index 84b173a76e5ff2..daf5e7f28f5f13 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -9,7 +9,7 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.novy_cooker_hood.light import COMMAND_LIGHT +from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID,