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..f79b450d8b670c --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,127 @@ +"""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 await self.async_step_test_failed() + return self.async_show_menu( + step_id="test_light", + menu_options=["finish", "retry"], + 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: + """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="Novy Cooker Hood", + 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..8673eb4be074bd --- /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_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +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.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Novy", + model="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..b4d6b0455be238 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -0,0 +1,77 @@ +"""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_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" + + 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 = entry.entry_id + + 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..92a53f4c2624af --- /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": ["rf-protocols==2.2.0"] +} 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..1a546af6fb9fa0 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -0,0 +1,60 @@ +{ + "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." + }, + "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": { + "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": "[%key:component::light::title%]" + } + } + }, + "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/requirements_all.txt b/requirements_all.txt index 3411c3c9ca01c5..d6b235015048cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2841,6 +2841,7 @@ 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.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a22b17aeca9f4..900fa6b8e6f1b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2425,6 +2425,7 @@ 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.2.0 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..cc600e554b31fb --- /dev/null +++ b/tests/components/novy_cooker_hood/conftest.py @@ -0,0 +1,66 @@ +"""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.common import ( + MockRadioFrequencyCommand, + MockRadioFrequencyEntity, +) + +TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" + + +@pytest.fixture(autouse=True) +def mock_get_codes() -> Iterator[MagicMock]: + """Patch the bundled-codes loader so tests don't hit the filesystem.""" + fake_collection = MagicMock(spec=CodeCollection) + fake_collection.async_load_command = AsyncMock( + side_effect=lambda name: MockRadioFrequencyCommand() + ) + with patch( + "homeassistant.components.novy_cooker_hood.light.get_codes", + return_value=fake_collection, + ): + yield fake_collection + + +@pytest.fixture +def mock_config_entry( + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> MockConfigEntry: + """Return a mock config entry for Novy Cooker Hood.""" + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + assert entity_entry is not None + return MockConfigEntry( + domain=DOMAIN, + title="Novy Cooker Hood", + 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..77f1da0e8e54d3 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test the Novy Hood config flow.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import 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.common import MockRadioFrequencyEntity + + +@pytest.fixture(autouse=True) +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: + """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" + 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 `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.MENU + assert result["step_id"] == "test_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..84b173a76e5ff2 --- /dev/null +++ b/tests/components/novy_cooker_hood/test_light.py @@ -0,0 +1,116 @@ +"""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_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 + +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 + + +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