From 7cec25bef57e1a7930afce1b0fbd55184688b44a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sun, 26 Apr 2026 15:03:51 +0200 Subject: [PATCH 1/5] Add Novy Cooker Hood integration --- CODEOWNERS | 2 + .../components/novy_cooker_hood/__init__.py | 20 ++ .../novy_cooker_hood/config_flow.py | 122 +++++++++++ .../components/novy_cooker_hood/const.py | 14 ++ .../components/novy_cooker_hood/entity.py | 76 +++++++ .../components/novy_cooker_hood/light.py | 79 +++++++ .../components/novy_cooker_hood/manifest.json | 12 + .../novy_cooker_hood/quality_scale.yaml | 109 +++++++++ .../components/novy_cooker_hood/strings.json | 56 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + tests/components/novy_cooker_hood/__init__.py | 1 + tests/components/novy_cooker_hood/conftest.py | 75 +++++++ .../novy_cooker_hood/test_config_flow.py | 207 ++++++++++++++++++ .../components/novy_cooker_hood/test_light.py | 88 ++++++++ 15 files changed, 868 insertions(+) create mode 100644 homeassistant/components/novy_cooker_hood/__init__.py create mode 100644 homeassistant/components/novy_cooker_hood/config_flow.py create mode 100644 homeassistant/components/novy_cooker_hood/const.py create mode 100644 homeassistant/components/novy_cooker_hood/entity.py create mode 100644 homeassistant/components/novy_cooker_hood/light.py create mode 100644 homeassistant/components/novy_cooker_hood/manifest.json create mode 100644 homeassistant/components/novy_cooker_hood/quality_scale.yaml create mode 100644 homeassistant/components/novy_cooker_hood/strings.json create mode 100644 tests/components/novy_cooker_hood/__init__.py create mode 100644 tests/components/novy_cooker_hood/conftest.py create mode 100644 tests/components/novy_cooker_hood/test_config_flow.py create mode 100644 tests/components/novy_cooker_hood/test_light.py diff --git a/CODEOWNERS b/CODEOWNERS index 4a852cf07c46ed..63e8f32ef88de9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1203,6 +1203,8 @@ CLAUDE.md @home-assistant/core /tests/components/notify_events/ @matrozov @papajojo /homeassistant/components/notion/ @bachya /tests/components/notion/ @bachya +/homeassistant/components/novy_cooker_hood/ @piitaya +/tests/components/novy_cooker_hood/ @piitaya /homeassistant/components/nrgkick/ @andijakl /tests/components/nrgkick/ @andijakl /homeassistant/components/nsw_fuel_station/ @nickw444 diff --git a/homeassistant/components/novy_cooker_hood/__init__.py b/homeassistant/components/novy_cooker_hood/__init__.py new file mode 100644 index 00000000000000..550617c9bc91f6 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/__init__.py @@ -0,0 +1,20 @@ +"""The Novy Cooker Hood integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Novy Cooker Hood from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py new file mode 100644 index 00000000000000..f357c4df785954 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import voluptuous as vol + +from homeassistant.components.radio_frequency import ( + async_get_transmitters, + async_send_command, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +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 + +_CODE_OPTIONS = [str(code) for code in range(CODE_MIN, CODE_MAX + 1)] +_TOGGLE_GAP = 1.5 + + +class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Novy Cooker Hood.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self._transmitter_entity_id: str | None = None + self._transmitter_id: str | None = None + self._code: int = DEFAULT_CODE + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick a transmitter and code.""" + try: + transmitters = async_get_transmitters(self.hass, FREQUENCY, MODULATION) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort(reason="no_compatible_transmitters") + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + code = int(user_input[CONF_CODE]) + await self.async_set_unique_id(f"{entity_entry.id}_{code}") + self._abort_if_unique_id_configured() + self._transmitter_entity_id = entity_entry.entity_id + self._transmitter_id = entity_entry.id + self._code = code + return await self.async_step_test_light() + + schema: dict[Any, Any] = { + vol.Required( + CONF_TRANSMITTER, + default=self._transmitter_entity_id or vol.UNDEFINED, + ): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + vol.Required(CONF_CODE, default=str(self._code)): selector.SelectSelector( + selector.SelectSelectorConfig( + options=_CODE_OPTIONS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="code", + ) + ), + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(schema), + ) + + async def async_step_test_light( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Toggle the hood light on then off so it ends in its starting state.""" + assert self._transmitter_entity_id is not None + 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) + except HomeAssistantError: + return self.async_show_form( + step_id="test_light", + data_schema=vol.Schema({}), + description_placeholders={"code": str(self._code)}, + errors={"base": "transmit_failed"}, + ) + return self.async_show_menu( + step_id="test_light", + menu_options=["finish", "retry"], + description_placeholders={"code": str(self._code)}, + ) + + async def async_step_retry( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Return to the code selection step.""" + return await self.async_step_user() + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create the config entry.""" + assert self._transmitter_id is not None + return self.async_create_entry( + title=f"Novy Cooker Hood (code {self._code})", + data={ + CONF_TRANSMITTER: self._transmitter_id, + CONF_CODE: self._code, + }, + ) diff --git a/homeassistant/components/novy_cooker_hood/const.py b/homeassistant/components/novy_cooker_hood/const.py new file mode 100644 index 00000000000000..45b4b3342bc54c --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -0,0 +1,14 @@ +"""Constants for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "novy_cooker_hood" + +CONF_TRANSMITTER: Final = "transmitter" +CONF_CODE: Final = "code" + +CODE_MIN: Final = 1 +CODE_MAX: Final = 10 +DEFAULT_CODE: Final = 1 diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py new file mode 100644 index 00000000000000..b0ac7be8226681 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -0,0 +1,76 @@ +"""Common entity for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_CODE, CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NovyCookerHoodEntity(Entity): + """Novy Cooker Hood base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + code = entry.data[CONF_CODE] + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_code_{code}")}, + manufacturer="Novy", + model="Cooker Hood", + name="Novy Cooker Hood", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py new file mode 100644 index 00000000000000..170d4f0886ac8f --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -0,0 +1,79 @@ +"""Light platform for the Novy Cooker Hood.""" + +from __future__ import annotations + +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 +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +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, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Novy Cooker Hood light platform.""" + async_add_entities([NovyCookerHoodLight(config_entry)]) + + +class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): + """Novy cooker hood light toggled via a single RF press.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" + _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the light.""" + super().__init__(entry) + self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._attr_unique_id = f"{entry.entry_id}_light" + + async def async_added_to_hass(self) -> None: + """Restore the last known on/off state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + 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) + 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) + 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) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/novy_cooker_hood/manifest.json b/homeassistant/components/novy_cooker_hood/manifest.json new file mode 100644 index 00000000000000..a7da50bed55f75 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "novy_cooker_hood", + "name": "Novy Cooker Hood", + "codeowners": ["@piitaya"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/novy_cooker_hood", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze", + "requirements": [] +} diff --git a/homeassistant/components/novy_cooker_hood/quality_scale.yaml b/homeassistant/components/novy_cooker_hood/quality_scale.yaml new file mode 100644 index 00000000000000..93a6fc2a244f29 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/quality_scale.yaml @@ -0,0 +1,109 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not register custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: done + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact at setup. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The light entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The light entity represents the primary device function. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + The light entity uses the default icon for its state. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json new file mode 100644 index 00000000000000..6359c6c4593524 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "error": { + "transmit_failed": "Failed to send the test command. Check the transmitter and try again." + }, + "step": { + "test_light": { + "description": "Toggled the hood light on and off using code {code}. Did you see it react? Press Finish to save, or Retry to pick a different code.", + "menu_options": { + "finish": "Finish", + "retry": "Retry" + }, + "title": "Verify the code" + }, + "user": { + "data": { + "code": "Code", + "transmitter": "Radio frequency transmitter" + }, + "data_description": { + "code": "The code your hood is paired with (1-10). Code 1 is the factory default.", + "transmitter": "The radio frequency transmitter used to control the Novy cooker hood." + }, + "description": "After you submit, Home Assistant will toggle the hood light on and off to verify the code works." + } + } + }, + "entity": { + "light": { + "light": { + "name": "Light" + } + } + }, + "selector": { + "code": { + "options": { + "1": "Code 1", + "2": "Code 2", + "3": "Code 3", + "4": "Code 4", + "5": "Code 5", + "6": "Code 6", + "7": "Code 7", + "8": "Code 8", + "9": "Code 9", + "10": "Code 10" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1a5237e768bc9a..c767ec88a07bdf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -498,6 +498,7 @@ "nobo_hub", "nordpool", "notion", + "novy_cooker_hood", "nrgkick", "ntfy", "nuheat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5993f39123e16d..79382b649e5cdb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4765,6 +4765,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "novy_cooker_hood": { + "name": "Novy Cooker Hood", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "nrgkick": { "name": "NRGkick", "integration_type": "device", diff --git a/tests/components/novy_cooker_hood/__init__.py b/tests/components/novy_cooker_hood/__init__.py new file mode 100644 index 00000000000000..cfc5235c1fdffc --- /dev/null +++ b/tests/components/novy_cooker_hood/__init__.py @@ -0,0 +1 @@ +"""Tests for the Novy Hood integration.""" diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py new file mode 100644 index 00000000000000..892d96f0c0e86e --- /dev/null +++ b/tests/components/novy_cooker_hood/conftest.py @@ -0,0 +1,75 @@ +"""Common fixtures for the Novy Cooker Hood tests.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from rf_protocols import CodeCollection + +from homeassistant.components.novy_cooker_hood.const import ( + CONF_CODE, + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.conftest import ( + MockRadioFrequencyCommand, + MockRadioFrequencyEntity, + init_integration, # noqa: F401 + mock_rf_entity, # noqa: F401 +) + +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.config_flow.get_codes_for_code", + return_value=fake_collection, + ), + ): + yield fake_collection + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, # noqa: F811 +) -> MockConfigEntry: + """Return a mock config entry for Novy Cooker Hood.""" + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert entity_entry is not None + return MockConfigEntry( + domain=DOMAIN, + title="Novy Cooker Hood (code 1)", + data={CONF_TRANSMITTER: entity_entry.id, CONF_CODE: 1}, + unique_id=f"{entity_entry.id}_1", + ) + + +@pytest.fixture +async def init_novy_cooker_hood( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Novy Cooker Hood integration.""" + 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() + return mock_config_entry diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py new file mode 100644 index 00000000000000..ade6d5257d23ca --- /dev/null +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -0,0 +1,207 @@ +"""Test the Novy Hood config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +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 +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity + + +@pytest.fixture(autouse=True) +def mock_toggle_sleep() -> AsyncMock: + """Skip the toggle gap during the test step.""" + with patch( + "homeassistant.components.novy_cooker_hood.config_flow.asyncio.sleep", + AsyncMock(), + ) as mocked: + yield mocked + + +async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict: + """Start the flow and submit the user step with the given code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert CONF_CODE in result["data_schema"].schema + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: code, + }, + ) + + +async def test_user_flow_test_then_finish( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Submitting the user step fires the test, then Finish creates the entry.""" + result = await _start_user_flow(hass, code="3") + + # Test was fired automatically (toggle on, wait, toggle off). + 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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Novy Cooker Hood (code 3)" + assert result["data"] == { + CONF_TRANSMITTER: entity_entry.id, + CONF_CODE: 3, + } + assert result["result"].unique_id == f"{entity_entry.id}_3" + + +async def test_user_flow_retry_picks_different_code( + hass: HomeAssistant, + mock_get_codes: MagicMock, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Retry returns to the user step; a new code re-fires the test and saves.""" + result = await _start_user_flow(hass, code="1") + assert result["type"] is FlowResultType.MENU + + # Pick Retry → back to user step. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "retry"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Submit a different code → test fires again, menu shown. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: "7", + }, + ) + 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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CODE] == 7 + + +async def test_user_flow_test_transmit_failure( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """A transmit failure surfaces as a `transmit_failed` form error.""" + with patch( + "homeassistant.components.novy_cooker_hood.config_flow.async_send_command", + side_effect=HomeAssistantError("nope"), + ): + result = await _start_user_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "test_light" + assert result["errors"] == {"base": "transmit_failed"} + + +async def test_unique_id_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when the same transmitter+code is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID, + CONF_CODE: "1", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "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, +) -> None: + """A second hood on the same transmitter but a different code is allowed.""" + mock_config_entry.add_to_hass(hass) + + result = await _start_user_flow(hass, code="5") + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finish"} + ) + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CODE] == 5 + assert result["result"].unique_id == f"{entity_entry.id}_5" + + +async def test_no_transmitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no RF transmitters are registered at all.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_transmitters" + + +async def test_no_compatible_transmitters(hass: HomeAssistant) -> None: + """Test aborting when transmitters exist but none support 433.92 MHz OOK.""" + assert await async_setup_component(hass, RF_DOMAIN, {}) + await hass.async_block_till_done() + incompatible = MockRadioFrequencyEntity( + "incompatible", frequency_ranges=[(868_000_000, 869_000_000)] + ) + await hass.data[DATA_COMPONENT].async_add_entities([incompatible]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_compatible_transmitters" diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py new file mode 100644 index 00000000000000..5c41990e6b794b --- /dev/null +++ b/tests/components/novy_cooker_hood/test_light.py @@ -0,0 +1,88 @@ +"""Tests for the Novy Hood light platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, call + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.novy_cooker_hood.light import COMMAND_LIGHT +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import Context, HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity + +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: + """Turn on sends a light toggle and flips is_on; turn off does the same.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + + await hass.services.async_call( + LIGHT_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.context is context + assert len(mock_rf_entity.send_command_calls) == 1 + assert mock_rf_entity.send_command_calls[0].context is context + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + 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 + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light restores its previous on state.""" + mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)]) + 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 From 280f51436ce4160eadc67c44b37b1683578838b5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 27 Apr 2026 21:07:11 +0200 Subject: [PATCH 2/5] Bump rf-protocols --- homeassistant/components/honeywell_string_lights/manifest.json | 2 +- homeassistant/components/novy_cooker_hood/manifest.json | 2 +- homeassistant/components/radio_frequency/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 3 ++- requirements_test_all.txt | 3 ++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/honeywell_string_lights/manifest.json b/homeassistant/components/honeywell_string_lights/manifest.json index 9924b711414631..62d65edb28e300 100644 --- a/homeassistant/components/honeywell_string_lights/manifest.json +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "assumed_state", "quality_scale": "bronze", - "requirements": ["rf-protocols==2.1.0"] + "requirements": ["rf-protocols==2.2.0"] } diff --git a/homeassistant/components/novy_cooker_hood/manifest.json b/homeassistant/components/novy_cooker_hood/manifest.json index a7da50bed55f75..92a53f4c2624af 100644 --- a/homeassistant/components/novy_cooker_hood/manifest.json +++ b/homeassistant/components/novy_cooker_hood/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "assumed_state", "quality_scale": "bronze", - "requirements": [] + "requirements": ["rf-protocols==2.2.0"] } diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json index 0c346c011b67e8..70797a9cb87641 100644 --- a/homeassistant/components/radio_frequency/manifest.json +++ b/homeassistant/components/radio_frequency/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_frequency", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["rf-protocols==2.1.0"] + "requirements": ["rf-protocols==2.2.0"] } diff --git a/requirements.txt b/requirements.txt index dd720c4db0071c..0d2178285f0e64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.33.1 -rf-protocols==2.1.0 +rf-protocols==2.2.0 securetar==2026.4.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index edaf414aadcc94..753d87958e36d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2841,8 +2841,9 @@ renson-endura-delta==1.7.2 reolink-aio==0.19.1 # homeassistant.components.honeywell_string_lights +# homeassistant.components.novy_cooker_hood # homeassistant.components.radio_frequency -rf-protocols==2.1.0 +rf-protocols==2.2.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1704dd8ce4e9f..60b8e9101bdce2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2425,8 +2425,9 @@ renson-endura-delta==1.7.2 reolink-aio==0.19.1 # homeassistant.components.honeywell_string_lights +# homeassistant.components.novy_cooker_hood # homeassistant.components.radio_frequency -rf-protocols==2.1.0 +rf-protocols==2.2.0 # homeassistant.components.rflink rflink==0.0.67 From f00de2c5643acf84fab993c8d368a4d185b1422a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 12:11:56 +0200 Subject: [PATCH 3/5] Fix tests and improve config flow --- .../novy_cooker_hood/config_flow.py | 19 ++++++++++++++----- .../components/novy_cooker_hood/entity.py | 7 +++---- .../components/novy_cooker_hood/strings.json | 10 +++++++--- tests/components/novy_cooker_hood/conftest.py | 8 +++----- .../novy_cooker_hood/test_config_flow.py | 11 +++++------ .../components/novy_cooker_hood/test_light.py | 2 +- 6 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py index f357c4df785954..0c1958dd995321 100644 --- a/homeassistant/components/novy_cooker_hood/config_flow.py +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -90,11 +90,10 @@ async def async_step_test_light( await asyncio.sleep(_TOGGLE_GAP) await async_send_command(self.hass, self._transmitter_entity_id, command) except HomeAssistantError: - return self.async_show_form( - step_id="test_light", - data_schema=vol.Schema({}), + return self.async_show_menu( + step_id="test_failed", + menu_options=["retry"], description_placeholders={"code": str(self._code)}, - errors={"base": "transmit_failed"}, ) return self.async_show_menu( step_id="test_light", @@ -102,6 +101,16 @@ async def async_step_test_light( description_placeholders={"code": str(self._code)}, ) + async def async_step_test_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Re-show the failure menu (only Retry available).""" + return self.async_show_menu( + step_id="test_failed", + menu_options=["retry"], + description_placeholders={"code": str(self._code)}, + ) + async def async_step_retry( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -114,7 +123,7 @@ async def async_step_finish( """Create the config entry.""" assert self._transmitter_id is not None return self.async_create_entry( - title=f"Novy Cooker Hood (code {self._code})", + title="Novy Cooker Hood", data={ CONF_TRANSMITTER: self._transmitter_id, CONF_CODE: self._code, diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py index b0ac7be8226681..b90a3f056edb8c 100644 --- a/homeassistant/components/novy_cooker_hood/entity.py +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_CODE, CONF_TRANSMITTER, DOMAIN +from .const import CONF_TRANSMITTER, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,13 +24,12 @@ class NovyCookerHoodEntity(Entity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - code = entry.data[CONF_CODE] self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.entry_id}_code_{code}")}, + identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Novy", model="Cooker Hood", - name="Novy Cooker Hood", + name=entry.title, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json index 6359c6c4593524..38d5a5142baf36 100644 --- a/homeassistant/components/novy_cooker_hood/strings.json +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -5,10 +5,14 @@ "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." }, - "error": { - "transmit_failed": "Failed to send the test command. Check the transmitter and try again." - }, "step": { + "test_failed": { + "description": "Could not send the test command for code {code}. Check that your radio frequency transmitter is online, then press Retry.", + "menu_options": { + "retry": "Retry" + }, + "title": "Test failed" + }, "test_light": { "description": "Toggled the hood light on and off using code {code}. Did you see it react? Press Finish to save, or Retry to pick a different code.", "menu_options": { diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index 892d96f0c0e86e..67d351d28bd8df 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -17,11 +17,9 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -from tests.components.radio_frequency.conftest import ( +from tests.components.radio_frequency.common import ( MockRadioFrequencyCommand, MockRadioFrequencyEntity, - init_integration, # noqa: F401 - mock_rf_entity, # noqa: F401 ) TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" @@ -50,7 +48,7 @@ def mock_get_codes() -> Iterator[MagicMock]: @pytest.fixture def mock_config_entry( hass: HomeAssistant, - mock_rf_entity: MockRadioFrequencyEntity, # noqa: F811 + mock_rf_entity: MockRadioFrequencyEntity, ) -> MockConfigEntry: """Return a mock config entry for Novy Cooker Hood.""" entity_registry = er.async_get(hass) @@ -58,7 +56,7 @@ def mock_config_entry( assert entity_entry is not None return MockConfigEntry( domain=DOMAIN, - title="Novy Cooker Hood (code 1)", + title="Novy Cooker Hood", data={CONF_TRANSMITTER: entity_entry.id, CONF_CODE: 1}, unique_id=f"{entity_entry.id}_1", ) diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py index ade6d5257d23ca..38c16ee96288eb 100644 --- a/tests/components/novy_cooker_hood/test_config_flow.py +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -23,7 +23,7 @@ from .conftest import TRANSMITTER_ENTITY_ID from tests.common import MockConfigEntry -from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity +from tests.components.radio_frequency.common import MockRadioFrequencyEntity @pytest.fixture(autouse=True) @@ -75,7 +75,7 @@ async def test_user_flow_test_then_finish( entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Novy Cooker Hood (code 3)" + assert result["title"] == "Novy Cooker Hood" assert result["data"] == { CONF_TRANSMITTER: entity_entry.id, CONF_CODE: 3, @@ -124,16 +124,15 @@ async def test_user_flow_test_transmit_failure( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, ) -> None: - """A transmit failure surfaces as a `transmit_failed` form error.""" + """A transmit failure surfaces as a `test_failed` menu with a Retry option.""" with patch( "homeassistant.components.novy_cooker_hood.config_flow.async_send_command", side_effect=HomeAssistantError("nope"), ): result = await _start_user_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "test_light" - assert result["errors"] == {"base": "transmit_failed"} + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "test_failed" async def test_unique_id_already_configured( diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index 5c41990e6b794b..e4181d529060c7 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -20,7 +20,7 @@ from homeassistant.core import Context, HomeAssistant, State from tests.common import MockConfigEntry, mock_restore_cache -from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity +from tests.components.radio_frequency.common import MockRadioFrequencyEntity ENTITY_ID = "light.novy_cooker_hood_light" From b2594433a4388c1dd8dbbbd4dc6afc8b1838d49c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 16:59:03 +0200 Subject: [PATCH 4/5] Add test failed test --- .../novy_cooker_hood/config_flow.py | 6 +--- tests/components/novy_cooker_hood/conftest.py | 12 ++------ .../components/novy_cooker_hood/test_light.py | 28 +++++++++++++++++++ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py index 0c1958dd995321..f79b450d8b670c 100644 --- a/homeassistant/components/novy_cooker_hood/config_flow.py +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -90,11 +90,7 @@ async def async_step_test_light( await asyncio.sleep(_TOGGLE_GAP) await async_send_command(self.hass, self._transmitter_entity_id, command) except HomeAssistantError: - return self.async_show_menu( - step_id="test_failed", - menu_options=["retry"], - description_placeholders={"code": str(self._code)}, - ) + return await self.async_step_test_failed() return self.async_show_menu( step_id="test_light", menu_options=["finish", "retry"], diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index 67d351d28bd8df..c99677b5520054 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -32,15 +32,9 @@ def mock_get_codes() -> Iterator[MagicMock]: 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.config_flow.get_codes_for_code", - return_value=fake_collection, - ), + with patch( + "homeassistant.components.novy_cooker_hood.light.get_codes", + return_value=fake_collection, ): yield fake_collection diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index e4181d529060c7..84b173a76e5ff2 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -15,10 +15,13 @@ ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, State +from .conftest import TRANSMITTER_ENTITY_ID + from tests.common import MockConfigEntry, mock_restore_cache from tests.components.radio_frequency.common import MockRadioFrequencyEntity @@ -86,3 +89,28 @@ async def test_restore_state( state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON + + +async def test_entity_follows_transmitter_availability( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """The light becomes unavailable when the transmitter does, and back.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + hass.states.async_set(TRANSMITTER_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TRANSMITTER_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE From da49b28069901f6e8662a6b37e249504937cc97b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Apr 2026 21:09:45 +0200 Subject: [PATCH 5/5] Review --- .../components/novy_cooker_hood/entity.py | 3 ++- homeassistant/components/novy_cooker_hood/light.py | 4 +--- .../components/novy_cooker_hood/strings.json | 2 +- tests/components/novy_cooker_hood/conftest.py | 3 +-- .../novy_cooker_hood/test_config_flow.py | 14 ++++++-------- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py index b90a3f056edb8c..8673eb4be074bd 100644 --- a/homeassistant/components/novy_cooker_hood/entity.py +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -20,7 +20,9 @@ class NovyCookerHoodEntity(Entity): """Novy Cooker Hood base entity.""" + _attr_assumed_state = True _attr_has_entity_name = True + _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" @@ -29,7 +31,6 @@ def __init__(self, entry: ConfigEntry) -> None: identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Novy", model="Cooker Hood", - name=entry.title, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index 170d4f0886ac8f..b4d6b0455be238 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -41,17 +41,15 @@ async def async_setup_entry( class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): """Novy cooker hood light toggled via a single RF press.""" - _attr_assumed_state = True _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: """Initialize the light.""" super().__init__(entry) self._codes = get_codes_for_code(entry.data[CONF_CODE]) - self._attr_unique_id = f"{entry.entry_id}_light" + self._attr_unique_id = entry.entry_id async def async_added_to_hass(self) -> None: """Restore the last known on/off state.""" diff --git a/homeassistant/components/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json index 38d5a5142baf36..1a546af6fb9fa0 100644 --- a/homeassistant/components/novy_cooker_hood/strings.json +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -37,7 +37,7 @@ "entity": { "light": { "light": { - "name": "Light" + "name": "[%key:component::light::title%]" } } }, diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index c99677b5520054..cc600e554b31fb 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -41,11 +41,10 @@ def mock_get_codes() -> Iterator[MagicMock]: @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, ) -> MockConfigEntry: """Return a mock config entry for Novy Cooker Hood.""" - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) assert entity_entry is not None return MockConfigEntry( diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py index 38c16ee96288eb..77f1da0e8e54d3 100644 --- a/tests/components/novy_cooker_hood/test_config_flow.py +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from collections.abc import Iterator +from unittest.mock import MagicMock, patch import pytest @@ -27,13 +28,10 @@ @pytest.fixture(autouse=True) -def mock_toggle_sleep() -> AsyncMock: - """Skip the toggle gap during the test step.""" - with patch( - "homeassistant.components.novy_cooker_hood.config_flow.asyncio.sleep", - AsyncMock(), - ) as mocked: - yield mocked +def mock_toggle_gap() -> Iterator[None]: + """Set the toggle gap to 0 so the test step doesn't actually wait.""" + with patch("homeassistant.components.novy_cooker_hood.config_flow._TOGGLE_GAP", 0): + yield async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict: