From e509c9b78a64fff6080c7ca6b43a5b83fe2b514f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:23:48 +0200 Subject: [PATCH 01/10] Migrate `onvif` to use `entry.runtime_data` (#169106) --- homeassistant/components/onvif/__init__.py | 22 +++++++------------ .../components/onvif/binary_sensor.py | 10 +++------ homeassistant/components/onvif/button.py | 10 +++------ homeassistant/components/onvif/camera.py | 10 +++------ homeassistant/components/onvif/device.py | 4 +++- homeassistant/components/onvif/diagnostics.py | 8 +++---- homeassistant/components/onvif/sensor.py | 10 +++------ homeassistant/components/onvif/switch.py | 10 +++------ tests/components/onvif/test_config_flow.py | 3 ++- 9 files changed, 31 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 7c773ad1e2ce1f..1749a0abe4f3ef 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,5 +1,4 @@ """The ONVIF integration.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from contextlib import AsyncExitStack, suppress @@ -13,7 +12,6 @@ from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, HTTP_BASIC_AUTHENTICATION, @@ -29,18 +27,14 @@ CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, - DOMAIN, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Set up ONVIF from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if not entry.options: await async_populate_options(hass, entry) @@ -97,7 +91,7 @@ async def _cleanup(): # If we get here, setup was successful - prevent cleanup stack.pop_all() - hass.data[DOMAIN][entry.unique_id] = device + entry.runtime_data = device device.platforms = [Platform.BUTTON, Platform.CAMERA] @@ -128,9 +122,9 @@ async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None: await device.device.close() -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> bool: """Unload a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data await _async_stop_device(hass, device) return await hass.config_entries.async_unload_platforms(entry, device.platforms) @@ -150,7 +144,7 @@ async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: async def async_populate_snapshot_auth( - hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry + hass: HomeAssistant, device: ONVIFDevice, entry: ONVIFConfigEntry ) -> None: """Check if digest auth for snapshots is possible.""" if auth := await _get_snapshot_auth(device): @@ -159,7 +153,7 @@ async def async_populate_snapshot_auth( ) -async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_populate_options(hass: HomeAssistant, entry: ONVIFConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, @@ -172,7 +166,7 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non @callback def _async_migrate_camera_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice + hass: HomeAssistant, config_entry: ONVIFConfigEntry, device: ONVIFDevice ) -> None: """Migrate unique ids of camera entities from profile index to profile token.""" entity_reg = er.async_get(hass) diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 0a9ac13ef7fa5f..d4caa6683fb614 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -6,7 +6,6 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -14,21 +13,18 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF binary sensor platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data events = device.events.get_platform("binary_sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index d654b96c727e91..1551e089dfbda3 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -1,25 +1,21 @@ """ONVIF Buttons.""" from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF button based on a config entry.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities([RebootButton(device), SetSystemDateAndTimeButton(device)]) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 2e72868bd93dd0..e2335b3f2dcfbb 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -17,7 +17,6 @@ CONF_USE_WALLCLOCK_AS_TIMESTAMPS, RTSP_TRANSPORTS, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -40,7 +39,6 @@ DIR_LEFT, DIR_RIGHT, DIR_UP, - DOMAIN, GOTOPRESET_MOVE, LOGGER, RELATIVE_MOVE, @@ -49,14 +47,14 @@ ZOOM_IN, ZOOM_OUT, ) -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ONVIF camera video stream.""" @@ -86,9 +84,7 @@ async def async_setup_entry( "async_perform_ptz", ) - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( [ONVIFCameraEntity(device, profile) for profile in device.profiles] ) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 7bcdd33809b057..61c2dafdf336f2 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -44,6 +44,8 @@ from .event_manager import EventManager from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video +type ONVIFConfigEntry = ConfigEntry[ONVIFDevice] + class ONVIFDevice: """Manages an ONVIF device.""" @@ -165,7 +167,7 @@ async def async_setup(self) -> None: # Bind the listener to the ONVIFDevice instance since # async_update_listener only creates a weak reference to the listener # and we need to make sure it doesn't get garbage collected since only - # the ONVIFDevice instance is stored in hass.data + # the ONVIFDevice instance is stored in config_entry.runtime_data self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) ) diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index aa2042f3321508..e7e49e8a3bf671 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -6,21 +6,19 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry REDACT_CONFIG = {CONF_HOST, CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ONVIFConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + device = entry.runtime_data data: dict[str, Any] = {} data["config"] = async_redact_data(entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index b3b60ad02f3475..29e323b649ca79 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -6,28 +6,24 @@ from decimal import Decimal from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .util import build_event_entity_names async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF sensor platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] + device: ONVIFDevice = config_entry.runtime_data events = device.events.get_platform("sensor") entity_names = build_event_entity_names(events) diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index 1ae491761c2b60..51442cd2acd294 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -7,12 +7,10 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .device import ONVIFDevice +from .device import ONVIFConfigEntry, ONVIFDevice from .entity import ONVIFBaseEntity from .models import Profile @@ -65,13 +63,11 @@ class ONVIFSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ONVIFConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF switch platform.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - device = hass.data[DOMAIN][config_entry.unique_id] + device = config_entry.runtime_data async_add_entities( ONVIFSwitch(device, description) diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 1d75b96aa11773..39326072149841 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,7 +6,8 @@ import pytest from homeassistant import config_entries -from homeassistant.components.onvif import DOMAIN, config_flow +from homeassistant.components.onvif import config_flow +from homeassistant.components.onvif.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant From 54e3c3fc9b0100358d2e6d5b08d1a3801cbfa653 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 09:26:28 +0200 Subject: [PATCH 02/10] Extract common entity base class for RDW (#169118) --- homeassistant/components/rdw/binary_sensor.py | 29 ++++-------------- homeassistant/components/rdw/entity.py | 27 +++++++++++++++++ homeassistant/components/rdw/sensor.py | 30 ++++--------------- 3 files changed, 37 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/rdw/entity.py diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index c62f3122efc6c6..5db3c446d63da2 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -13,12 +13,10 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity PARALLEL_UPDATES = 0 @@ -51,45 +49,28 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" - coordinator = entry.runtime_data async_add_entities( - RDWBinarySensorEntity( - coordinator=coordinator, - description=description, - ) + RDWBinarySensorEntity(entry.runtime_data, description) for description in BINARY_SENSORS - if description.is_on_fn(coordinator.data) is not None + if description.is_on_fn(entry.runtime_data.data) is not None ) -class RDWBinarySensorEntity( - CoordinatorEntity[RDWDataUpdateCoordinator], BinarySensorEntity -): +class RDWBinarySensorEntity(RDWEntity, BinarySensorEntity): """Defines an RDW binary sensor.""" entity_description: RDWBinarySensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, description: RDWBinarySensorEntityDescription, ) -> None: """Initialize RDW binary sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.data.license_plate)}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) - @property def is_on(self) -> bool: """Return the state of the sensor.""" diff --git a/homeassistant/components/rdw/entity.py b/homeassistant/components/rdw/entity.py new file mode 100644 index 00000000000000..df94f77b738550 --- /dev/null +++ b/homeassistant/components/rdw/entity.py @@ -0,0 +1,27 @@ +"""Base entity for the RDW integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RDWDataUpdateCoordinator + + +class RDWEntity(CoordinatorEntity[RDWDataUpdateCoordinator]): + """Defines an RDW entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RDWDataUpdateCoordinator) -> None: + """Initialize an RDW entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.data.license_plate)}, + manufacturer=coordinator.data.brand, + name=f"{coordinator.data.brand} {coordinator.data.license_plate}", + model=coordinator.data.model, + configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", + ) diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index a4b8bf98659b8d..ad88d2eaabdf9b 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -14,12 +14,10 @@ SensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_LICENSE_PLATE, DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +from .entity import RDWEntity PARALLEL_UPDATES = 0 @@ -53,43 +51,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" - coordinator = entry.runtime_data async_add_entities( - RDWSensorEntity( - coordinator=coordinator, - license_plate=entry.data[CONF_LICENSE_PLATE], - description=description, - ) - for description in SENSORS + RDWSensorEntity(entry.runtime_data, description) for description in SENSORS ) -class RDWSensorEntity(CoordinatorEntity[RDWDataUpdateCoordinator], SensorEntity): +class RDWSensorEntity(RDWEntity, SensorEntity): """Defines an RDW sensor.""" entity_description: RDWSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - *, coordinator: RDWDataUpdateCoordinator, - license_plate: str, description: RDWSensorEntityDescription, ) -> None: """Initialize RDW sensor.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{license_plate}_{description.key}" - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{license_plate}")}, - manufacturer=coordinator.data.brand, - name=f"{coordinator.data.brand} {coordinator.data.license_plate}", - model=coordinator.data.model, - configuration_url=f"https://ovi.rdw.nl/default.aspx?kenteken={coordinator.data.license_plate}", - ) + self._attr_unique_id = f"{coordinator.data.license_plate}_{description.key}" @property def native_value(self) -> date | str | float | None: From 757deb3a1cbbda59f710e392f67c436f3f09c3cb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:29:38 +0200 Subject: [PATCH 03/10] Add reconfiguration flow to Notifications for Android TV / Fire TV (#169111) --- .../components/nfandroidtv/config_flow.py | 27 ++++- .../components/nfandroidtv/strings.json | 12 +- tests/components/nfandroidtv/conftest.py | 37 +++++++ .../nfandroidtv/test_config_flow.py | 103 +++++++++++++++++- 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 tests/components/nfandroidtv/conftest.py diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index 9a5e420bbdce23..34893702b5dfcb 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_NAME from .const import DEFAULT_NAME, DOMAIN @@ -40,6 +40,31 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for Notification for Android TV / Fire TV.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match(user_input) + if not (error := await self._async_try_connect(user_input[CONF_HOST])): + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + errors["base"] = error + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + suggested_values=user_input or entry.data, + ), + description_placeholders={CONF_NAME: entry.title}, + errors=errors, + ) + async def _async_try_connect(self, host: str) -> str | None: """Try connecting to Android TV / Fire TV.""" try: diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index 531a6af1617bba..79c9648942b7d8 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -1,13 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::nfandroidtv::config::step::user::data_description::host%]" + }, + "description": "Reconfigure {name}" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/nfandroidtv/conftest.py b/tests/components/nfandroidtv/conftest.py new file mode 100644 index 00000000000000..6129b36ce93b39 --- /dev/null +++ b/tests/components/nfandroidtv/conftest.py @@ -0,0 +1,37 @@ +"""Common fixtures for the Notifications for Android TV / Fire TV tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.nfandroidtv.const import DOMAIN +from homeassistant.const import CONF_HOST + +from . import HOST, NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_notifications_android_tv() -> Generator[MagicMock]: + """Mock notifications_android_tv.""" + + with patch( + "homeassistant.components.nfandroidtv.config_flow.Notifications", autospec=True + ) as mock_client: + client = mock_client.return_value + client.cls = mock_client + + yield client + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Notifications for Android TV / Fire TV configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_HOST: HOST}, + entry_id="123456789", + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py index 271961fbee70b9..2daa18eb524389 100644 --- a/tests/components/nfandroidtv/test_config_flow.py +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -1,11 +1,13 @@ """Test NFAndroidTV config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from notifications_android_tv.notifications import ConnectError +import pytest from homeassistant import config_entries from homeassistant.components.nfandroidtv.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -97,3 +99,102 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("mock_notifications_android_tv") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "4.3.2.1" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), [(ConnectError, "cannot_connect"), (ValueError, "unknown")] +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_notifications_android_tv: MagicMock, + config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_notifications_android_tv.cls.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_notifications_android_tv.cls.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "4.3.2.1" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_notifications_android_tv") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts if already configured.""" + MockConfigEntry( + domain=DOMAIN, + title="Android TV / Fire TV (4.3.2.1)", + data={CONF_HOST: "4.3.2.1"}, + entry_id="987654321", + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "4.3.2.1"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 From 6862b808ae9d26b813b0146b06d2193dcf1f8c0f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 09:30:44 +0200 Subject: [PATCH 04/10] Update fumis to v0.4.0 (#169097) --- homeassistant/components/fumis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fumis/manifest.json b/homeassistant/components/fumis/manifest.json index 0ab8c7be5d063c..51182f92d3960b 100644 --- a/homeassistant/components/fumis/manifest.json +++ b/homeassistant/components/fumis/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_polling", "loggers": ["fumis"], "quality_scale": "bronze", - "requirements": ["fumis==0.3.0"] + "requirements": ["fumis==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 735954907e2b64..a08835d717da3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.3.0 +fumis==0.4.0 # homeassistant.components.fyta fyta_cli==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 192fa077b1a6db..edecc311f5b145 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -920,7 +920,7 @@ fressnapftracker==0.2.2 fritzconnection[qr]==1.15.1 # homeassistant.components.fumis -fumis==0.3.0 +fumis==0.4.0 # homeassistant.components.fyta fyta_cli==0.7.2 From 1978c9772a97996eef63dd4669e529ce92841aa1 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 Apr 2026 10:14:10 +0200 Subject: [PATCH 05/10] Filter unknown values from arcam enum (#169124) --- homeassistant/components/arcam_fmj/sensor.py | 47 ++++++++++++-------- tests/components/arcam_fmj/test_sensor.py | 34 ++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py index a415f92864a098..03dacd54045384 100644 --- a/homeassistant/components/arcam_fmj/sensor.py +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -4,8 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass +import logging -from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace +from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace, IntOrTypeEnum from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State from homeassistant.components.sensor import ( @@ -21,6 +22,25 @@ from .coordinator import ArcamFmjConfigEntry from .entity import ArcamFmjEntity +_LOGGER = logging.getLogger(__name__) + + +def _enum_options(value: type[IntOrTypeEnum]) -> list[str]: + return [ + member.name.lower() for member in value if not member.name.startswith("CODE_") + ] + + +def _enum_value(value: IntOrTypeEnum | None) -> str | None: + if value is None: + return None + + if value.name.startswith("CODE_"): + _LOGGER.debug("Undefined enum value %s ignored", value) + return None + + return value.name.lower() + @dataclass(frozen=True, kw_only=True) class ArcamFmjSensorEntityDescription(SensorEntityDescription): @@ -75,9 +95,9 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_video_aspect_ratio", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoAspectRatio], + options=_enum_options(IncomingVideoAspectRatio), value_fn=lambda state: ( - vp.aspect_ratio.name.lower() + _enum_value(vp.aspect_ratio) if (vp := state.get_incoming_video_parameters()) is not None else None ), @@ -87,11 +107,10 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_video_colorspace", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoColorspace], + options=_enum_options(IncomingVideoColorspace), value_fn=lambda state: ( - vp.colorspace.name.lower() + _enum_value(vp.colorspace) if (vp := state.get_incoming_video_parameters()) is not None - and vp.colorspace is not None else None ), ), @@ -100,24 +119,16 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): translation_key="incoming_audio_format", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioFormat], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[0]) is not None - else None - ), + options=_enum_options(IncomingAudioFormat), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[0]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_config", translation_key="incoming_audio_config", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioConfig], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[1]) is not None - else None - ), + options=_enum_options(IncomingAudioConfig), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[1]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_sample_rate", diff --git a/tests/components/arcam_fmj/test_sensor.py b/tests/components/arcam_fmj/test_sensor.py index 016c5e9850bf16..d214759cc52eab 100644 --- a/tests/components/arcam_fmj/test_sensor.py +++ b/tests/components/arcam_fmj/test_sensor.py @@ -92,3 +92,37 @@ async def test_sensor_audio_parameters( hass.states.get("sensor.arcam_fmj_127_0_0_1_incoming_audio_sample_rate").state == "48000" ) + + +@pytest.mark.usefixtures("player_setup") +async def test_sensor_enum_unknown( + hass: HomeAssistant, + state_1: State, + client: Mock, +) -> None: + """Test parameter sensors with unknown data.""" + video_params = Mock() + video_params.horizontal_resolution = 0 + video_params.vertical_resolution = 0 + video_params.refresh_rate = 0 + video_params.aspect_ratio = IncomingVideoAspectRatio.from_int(0x99) + video_params.colorspace = IncomingVideoColorspace.from_int(0x99) + + state_1.get_incoming_video_parameters.return_value = video_params + state_1.get_incoming_audio_format.return_value = ( + None, + IncomingAudioConfig.from_int(0x99), + ) + + client.notify_data_updated() + await hass.async_block_till_done() + + def _get(key: str) -> str: + state = hass.states.get(f"sensor.arcam_fmj_127_0_0_1_{key}") + assert state + return state.state + + assert _get("incoming_audio_format") == "unknown" + assert _get("incoming_audio_configuration") == "unknown" + assert _get("incoming_video_aspect_ratio") == "unknown" + assert _get("incoming_video_colorspace") == "unknown" From 77fd120cd5c71b8df5ae7c150d272d3ba0f7783c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 Apr 2026 10:15:33 +0200 Subject: [PATCH 06/10] Protect update coordinator callbacks (#169122) --- homeassistant/helpers/update_coordinator.py | 9 +++++- tests/helpers/test_update_coordinator.py | 33 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 7bed9ca1f28502..aac3ecec0fd9b1 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -199,7 +199,14 @@ def __async_remove_listener_internal(self, listener_id: int) -> None: def async_update_listeners(self) -> None: """Update all registered listeners.""" for update_callback, _ in list(self._listeners.values()): - update_callback() + try: + update_callback() + except Exception: + self.logger.exception( + "Unexpected error updating listener %s for %s", + id(update_callback), + self.name, + ) async def async_shutdown(self) -> None: """Cancel any scheduled call, and ignore new runs.""" diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index d4a35c7aa55523..4c9ad8550a7008 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1333,3 +1333,36 @@ async def test_refresh_known_errors_retry_after( unsub() crd._unschedule_refresh() + + +async def test_callbacks_does_not_stop_coordinator( + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_refresh for update coordinator.""" + + update_1 = Mock() + update_2 = Mock() + + crd.async_add_listener(update_1) + crd.async_add_listener(update_2) + await crd.async_refresh() + assert update_1.call_count == 1 + assert update_2.call_count == 1 + assert crd.last_update_success is True + + # Trigger exception in callback + update_1.side_effect = Exception("Failure in callback") + caplog.clear() + await crd.async_refresh() + assert any( + message.startswith("Unexpected error updating listener ") + for message in caplog.messages + ) + + # All callbacks should still have been called + assert update_1.call_count == 2 + assert update_2.call_count == 2 + assert crd.last_update_success is True + + await crd.async_shutdown() From c3f66f9e9094c043002214fbcaaa1e80d267ddb4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 10:27:57 +0200 Subject: [PATCH 07/10] Set parallel updates to 0 for Forecast.Solar (#169126) --- homeassistant/components/forecast_solar/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 13a4d5c2d232ec..55493103a7ce26 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,6 +27,8 @@ from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): From 5a79dd9d9909cb75a61c02954a9436c63cdd09ef Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 25 Apr 2026 09:42:16 +0100 Subject: [PATCH 08/10] Bump aiomealie to 1.2.4 (#169125) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mealie/fixtures/get_mealplans.json | 2 +- tests/components/mealie/snapshots/test_diagnostics.ambr | 5 +---- tests/components/mealie/snapshots/test_services.ambr | 2 +- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 01b3e2212680c0..b86669364f706c 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.2.3"] + "requirements": ["aiomealie==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index a08835d717da3b..3e0e24b239152b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -324,7 +324,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.3 +aiomealie==1.2.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edecc311f5b145..f79e7697e96ffb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.3 +aiomealie==1.2.4 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json index 6aa40e471bd1cd..f28054e3d99d12 100644 --- a/tests/components/mealie/fixtures/get_mealplans.json +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -325,7 +325,7 @@ "tools": [], "rating": null, "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", - "dateAdded": "2024-01-21", + "dateAdded": null, "dateUpdated": "2024-01-21T03:04:25.718367", "createdAt": "2024-01-21T02:13:11.323363", "updateAt": "2024-01-21T03:04:25.721489", diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 8d2877686a7572..003414fad15db6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -618,10 +618,7 @@ 'recipe': dict({ 'categories': list([ ]), - 'date_added': dict({ - '__type': "", - 'isoformat': '2024-01-21', - }), + 'date_added': None, 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index ff25b1e6072ef1..6298e711859eb1 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -3970,7 +3970,7 @@ 'recipe': dict({ 'categories': list([ ]), - 'date_added': HAFakeDate(2024, 1, 21), + 'date_added': None, 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, From 9e1c02262eeb4b88f07cd196a70e8b84ff82168d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 10:47:33 +0200 Subject: [PATCH 09/10] Set parallel updates for Hydrawise platforms (#169101) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/hydrawise/binary_sensor.py | 2 ++ homeassistant/components/hydrawise/sensor.py | 2 ++ homeassistant/components/hydrawise/switch.py | 2 ++ homeassistant/components/hydrawise/valve.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index d7344f56ab57d0..5a1abf2b02480d 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -23,6 +23,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 19fcd0295a2cb8..2880ef7ca1aa9c 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -22,6 +22,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class HydrawiseSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 238e249e1f69bf..5ba88d6d7fcbb7 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -22,6 +22,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HydrawiseSwitchEntityDescription(SwitchEntityDescription): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 56dd56e7d21dd7..9ed55ae9beec11 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -19,6 +19,8 @@ from .coordinator import HydrawiseConfigEntry from .entity import HydrawiseEntity +PARALLEL_UPDATES = 1 + VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( ValveEntityDescription( key="zone", From 48b650c48602d13f90610e092695a96f46718541 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Apr 2026 11:03:15 +0200 Subject: [PATCH 10/10] Modernize RDW config flow tests (#169129) --- tests/components/rdw/conftest.py | 27 ++---- tests/components/rdw/test_config_flow.py | 105 ++++++++++++----------- 2 files changed, 62 insertions(+), 70 deletions(-) diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py index 328f347f3ee956..cba499e6236045 100644 --- a/tests/components/rdw/conftest.py +++ b/tests/components/rdw/conftest.py @@ -33,32 +33,19 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture -def mock_rdw_config_flow() -> Generator[MagicMock]: +def mock_rdw() -> Generator[MagicMock]: """Return a mocked RDW client.""" - with patch( - "homeassistant.components.rdw.config_flow.RDW", autospec=True - ) as rdw_mock: + with ( + patch( + "homeassistant.components.rdw.coordinator.RDW", autospec=True + ) as rdw_mock, + patch("homeassistant.components.rdw.config_flow.RDW", new=rdw_mock), + ): rdw = rdw_mock.return_value rdw.vehicle.return_value = Vehicle.from_json(load_fixture("rdw/11ZKZ3.json")) yield rdw -@pytest.fixture -def mock_rdw(request: pytest.FixtureRequest) -> Generator[MagicMock]: - """Return a mocked WLED client.""" - fixture: str = "rdw/11ZKZ3.json" - if hasattr(request, "param") and request.param: - fixture = request.param - - vehicle = Vehicle.from_json(load_fixture(fixture)) - with patch( - "homeassistant.components.rdw.coordinator.RDW", autospec=True - ) as rdw_mock: - rdw = rdw_mock.return_value - rdw.vehicle.return_value = vehicle - yield rdw - - @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_rdw: MagicMock diff --git a/tests/components/rdw/test_config_flow.py b/tests/components/rdw/test_config_flow.py index 2aa39f2c2d34fe..7db70a5deb09e0 100644 --- a/tests/components/rdw/test_config_flow.py +++ b/tests/components/rdw/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +import pytest from vehicle.exceptions import RDWConnectionError, RDWUnknownLicensePlateError from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN @@ -9,81 +10,85 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry -async def test_full_user_flow( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock -) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_rdw") +async def test_full_user_flow(hass: HomeAssistant) -> None: """Test the full user configuration flow.""" 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 result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "11-ZKZ-3", - }, + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == "11-ZKZ-3" - assert result2.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} - - -async def test_full_flow_with_authentication_error( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock, mock_setup_entry: MagicMock + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "11-ZKZ-3" + assert result["data"] == {CONF_LICENSE_PLATE: "11ZKZ3"} + assert result["result"].unique_id == "11ZKZ3" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (RDWUnknownLicensePlateError, {"base": "unknown_license_plate"}), + (RDWConnectionError, {"base": "cannot_connect"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_rdw: MagicMock, + side_effect: type[Exception], + expected_error: dict[str, str], ) -> None: - """Test the full user configuration flow with incorrect license plate. + """Test the user flow with errors and recovery.""" + mock_rdw.vehicle.side_effect = side_effect - This tests tests a full config flow, with a case the user enters an invalid - license plate, but recover by entering the correct one. - """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - mock_rdw_config_flow.vehicle.side_effect = RDWUnknownLicensePlateError - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "0001TJ", - }, + user_input={CONF_LICENSE_PLATE: "0001TJ"}, ) - assert result2.get("type") is FlowResultType.FORM - assert result2.get("step_id") == "user" - assert result2.get("errors") == {"base": "unknown_license_plate"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == expected_error - mock_rdw_config_flow.vehicle.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={ - CONF_LICENSE_PLATE: "11-ZKZ-3", - }, + mock_rdw.vehicle.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "11-ZKZ-3" - assert result3.get("data") == {CONF_LICENSE_PLATE: "11ZKZ3"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_connection_error( - hass: HomeAssistant, mock_rdw_config_flow: MagicMock +@pytest.mark.usefixtures("mock_rdw") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: - """Test API connection error.""" - mock_rdw_config_flow.vehicle.side_effect = RDWConnectionError + """Test the user flow when the vehicle is already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_LICENSE_PLATE: "0001TJ"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LICENSE_PLATE: "11-ZKZ-3"}, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"