From 2507e88c5adb74d0cb759d024b667a601bd7f036 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sun, 26 Apr 2026 15:03:51 +0200 Subject: [PATCH 1/7] Add Novy Cooker Hood integration --- homeassistant/components/novy_cooker_hood/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/novy_cooker_hood/__init__.py b/homeassistant/components/novy_cooker_hood/__init__.py index 550617c9bc91f..4e21a91fb91cf 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: From c9c6ce8e657fda420f74f2ee00f6e4db0efad2cf Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 12:11:56 +0200 Subject: [PATCH 2/7] Fix tests and improve config flow --- .claude/settings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000000..5d6e0f00ea7d3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch *)", + "Bash(sed -n '105,115p' homeassistant/components/novy_cooker_hood/config_flow.py)", + "Bash(sed -n '50,65p' homeassistant/components/novy_cooker_hood/entity.py)", + "Bash(sed -n '27,32p' homeassistant/components/novy_cooker_hood/light.py)" + ] + } +} From b26578dc08c8487f74a30f5c76696dd5d46c7408 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 16:51:13 +0200 Subject: [PATCH 3/7] Add fan platform to Novy Cooker Hood --- .../components/novy_cooker_hood/commands.py | 16 ++ .../novy_cooker_hood/config_flow.py | 13 +- .../components/novy_cooker_hood/const.py | 7 + .../components/novy_cooker_hood/fan.py | 133 ++++++++++ .../components/novy_cooker_hood/light.py | 12 +- tests/components/novy_cooker_hood/conftest.py | 2 +- .../novy_cooker_hood/test_config_flow.py | 2 +- tests/components/novy_cooker_hood/test_fan.py | 239 ++++++++++++++++++ .../components/novy_cooker_hood/test_light.py | 2 +- 9 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/novy_cooker_hood/commands.py create mode 100644 homeassistant/components/novy_cooker_hood/fan.py create mode 100644 tests/components/novy_cooker_hood/test_fan.py diff --git a/homeassistant/components/novy_cooker_hood/commands.py b/homeassistant/components/novy_cooker_hood/commands.py new file mode 100644 index 0000000000000..976bb3ae0c9e4 --- /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 f79b450d8b670..d1dd5f0314823 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 45b4b3342bc54..0d4c06154f2ed 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 0000000000000..8e0c8047a5b8a --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,133 @@ +"""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 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_assumed_state = True + _attr_name = None + _attr_speed_count = SPEED_COUNT + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + _attr_should_poll = False + + 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 = f"{entry.entry_id}_fan" + + @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("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 one hardware level (single plus, no recalibration).""" + plus = await self._codes.async_load_command(COMMAND_PLUS) + await self._async_send(plus) + self._level = min(SPEED_COUNT, self._level + 1) + self.async_write_ha_state() + + async def async_decrease_speed(self, percentage_step: int | None = None) -> None: + """Bump speed down by one hardware level (single minus, no recalibration).""" + minus = await self._codes.async_load_command(COMMAND_MINUS) + await self._async_send(minus) + self._level = max(0, self._level - 1) + self.async_write_ha_state() + + 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) + plus = await self._codes.async_load_command(COMMAND_PLUS) + for _ in range(SPEED_COUNT): + await self._async_send(minus) + 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 b4d6b0455be23..9061a27545806 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 cc600e554b31f..b7f835e62d850 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 77f1da0e8e54d..c920c51ab2b5f 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 0000000000000..f5889353f1d88 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -0,0 +1,239 @@ +"""Tests for the Novy Hood fan platform.""" + +from __future__ import annotations + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + 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_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 84b173a76e5ff..daf5e7f28f5f1 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, From 64244bc97dc1c16e05428945591a53589710f68b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 18:25:35 +0200 Subject: [PATCH 4/7] Add support for step in increase / decrease --- .../components/novy_cooker_hood/fan.py | 27 +++++++---- tests/components/novy_cooker_hood/test_fan.py | 46 +++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index 8e0c8047a5b8a..afc5c5682827f 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -5,7 +5,7 @@ import math from typing import Any -from homeassistant.components.fan import FanEntity, FanEntityFeature +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 @@ -72,7 +72,7 @@ async def async_added_to_hass(self) -> None: last = await self.async_get_last_state() if last is None: return - last_pct = last.attributes.get("percentage") + 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)) @@ -102,19 +102,30 @@ async def async_set_percentage(self, percentage: int) -> None: await self._async_set_level(level) async def async_increase_speed(self, percentage_step: int | None = None) -> None: - """Bump speed up by one hardware level (single plus, no recalibration).""" + """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) - await self._async_send(plus) - self._level = min(SPEED_COUNT, self._level + 1) + 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 one hardware level (single minus, no recalibration).""" + """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) - await self._async_send(minus) - self._level = max(0, self._level - 1) + 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 max(1, 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) diff --git a/tests/components/novy_cooker_hood/test_fan.py b/tests/components/novy_cooker_hood/test_fan.py index f5889353f1d88..ae63034c53063 100644 --- a/tests/components/novy_cooker_hood/test_fan.py +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -4,6 +4,7 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, @@ -200,6 +201,51 @@ async def test_decrease_speed_sends_single_minus( 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, From 21a7350d6dae6e00e862a933e1326be6584bcef8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 18:27:14 +0200 Subject: [PATCH 5/7] Fix max --- homeassistant/components/novy_cooker_hood/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index afc5c5682827f..44dbd7db37b23 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -124,7 +124,7 @@ 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 max(1, math.ceil(percentage_step * SPEED_COUNT / 100)) + 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.""" From 01144d51524a293e107b63662a642ef24c6f9070 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 18:36:51 +0200 Subject: [PATCH 6/7] Avoid load command when not needed --- homeassistant/components/novy_cooker_hood/fan.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index 44dbd7db37b23..9d874efe01e98 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -129,11 +129,12 @@ def _steps_from_percentage(percentage_step: int | None) -> int: 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) - plus = await self._codes.async_load_command(COMMAND_PLUS) for _ in range(SPEED_COUNT): await self._async_send(minus) - for _ in range(level): - await self._async_send(plus) + 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() From 4ad174034a614f582291c1bd1a46b30eae9ddaa2 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 21:22:20 +0200 Subject: [PATCH 7/7] Rebase --- .claude/settings.json | 10 ---------- homeassistant/components/novy_cooker_hood/fan.py | 4 +--- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 5d6e0f00ea7d3..0000000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git fetch *)", - "Bash(sed -n '105,115p' homeassistant/components/novy_cooker_hood/config_flow.py)", - "Bash(sed -n '50,65p' homeassistant/components/novy_cooker_hood/entity.py)", - "Bash(sed -n '27,32p' homeassistant/components/novy_cooker_hood/light.py)" - ] - } -} diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index 9d874efe01e98..287ce19c88dda 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -37,7 +37,6 @@ async def async_setup_entry( class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Calibration-based fan: each change resets to off then climbs to target.""" - _attr_assumed_state = True _attr_name = None _attr_speed_count = SPEED_COUNT _attr_supported_features = ( @@ -45,14 +44,13 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): | FanEntityFeature.TURN_OFF | FanEntityFeature.SET_SPEED ) - _attr_should_poll = False 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 = f"{entry.entry_id}_fan" + self._attr_unique_id = entry.entry_id @property def is_on(self) -> bool: