From 836353015bb970761c8451e91b647e5ddb5f1b06 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Tue, 17 Mar 2026 12:04:31 -0700 Subject: [PATCH] Detect new garage doors automatically in aladdin_connect (#165004) --- .../components/aladdin_connect/__init__.py | 17 ++----- .../components/aladdin_connect/coordinator.py | 26 +++++----- .../components/aladdin_connect/cover.py | 33 ++++++++---- .../components/aladdin_connect/diagnostics.py | 14 ++--- .../components/aladdin_connect/entity.py | 24 ++++++--- .../aladdin_connect/quality_scale.yaml | 2 +- .../components/aladdin_connect/sensor.py | 32 ++++++++---- .../components/aladdin_connect/test_cover.py | 2 +- tests/components/aladdin_connect/test_init.py | 51 ++++++++++++++++++- .../components/aladdin_connect/test_sensor.py | 2 +- 10 files changed, 138 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 2af0f4e88599..25e5426d23c4 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -46,19 +46,10 @@ async def async_setup_entry( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - try: - doors = await client.get_doors() - except aiohttp.ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed(err) from err - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: - raise ConfigEntryNotReady from err + coordinator = AladdinConnectCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() - entry.runtime_data = { - door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) - for door in doors - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -100,7 +91,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = set(config_entry.runtime_data) + all_device_ids = set(config_entry.runtime_data.data) for device_entry in device_entries: device_id: str | None = None diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py index 3f6e3cb4eb60..c18ef8e0bbfb 100644 --- a/homeassistant/components/aladdin_connect/coordinator.py +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) -type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]] +type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator] SCAN_INTERVAL = timedelta(seconds=15) -class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]): +class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]): """Coordinator for Aladdin Connect integration.""" + config_entry: AladdinConnectConfigEntry + def __init__( self, hass: HomeAssistant, entry: AladdinConnectConfigEntry, client: AladdinConnectClient, - garage_door: GarageDoor, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]): update_interval=SCAN_INTERVAL, ) self.client = client - self.data = garage_door - async def _async_update_data(self) -> GarageDoor: + async def _async_update_data(self) -> dict[str, GarageDoor]: """Fetch data from the Aladdin Connect API.""" try: - await self.client.update_door(self.data.device_id, self.data.door_number) + doors = await self.client.get_doors() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise UpdateFailed(f"Error communicating with API: {err}") from err except aiohttp.ClientError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - self.data.status = self.client.get_door_status( - self.data.device_id, self.data.door_number - ) - self.data.battery_level = self.client.get_battery_status( - self.data.device_id, self.data.door_number - ) - return self.data + + return {door.unique_id: door for door in doors} diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 2cd9f8c28717..e6c5d0457b51 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -7,7 +7,7 @@ from typing import Any import aiohttp from homeassistant.components.cover import CoverDeviceClass, CoverEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,11 +24,22 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform.""" - coordinators = entry.runtime_data + coordinator = entry.runtime_data + known_devices: set[str] = set() - async_add_entities( - AladdinCoverEntity(coordinator) for coordinator in coordinators.values() - ) + @callback + def _async_add_new_devices() -> None: + """Detect and add entities for new doors.""" + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AladdinCoverEntity(coordinator, door_id) for door_id in new_devices + ) + + _async_add_new_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): @@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): _attr_supported_features = SUPPORTED_FEATURES _attr_name = None - def __init__(self, coordinator: AladdinConnectCoordinator) -> None: + def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None: """Initialize the Aladdin Connect cover.""" - super().__init__(coordinator) - self._attr_unique_id = coordinator.data.unique_id + super().__init__(coordinator, door_id) + self._attr_unique_id = door_id async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" @@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - if (status := self.coordinator.data.status) is None: + if (status := self.door.status) is None: return None return status == "closed" @property def is_closing(self) -> bool | None: """Update is closing attribute.""" - return self.coordinator.data.status == "closing" + return self.door.status == "closing" @property def is_opening(self) -> bool | None: """Update is opening attribute.""" - return self.coordinator.data.status == "opening" + return self.door.status == "opening" diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py index 804a401daf1c..583141bbca0b 100644 --- a/homeassistant/components/aladdin_connect/diagnostics.py +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics( "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), "doors": { uid: { - "device_id": coordinator.data.device_id, - "door_number": coordinator.data.door_number, - "name": coordinator.data.name, - "status": coordinator.data.status, - "link_status": coordinator.data.link_status, - "battery_level": coordinator.data.battery_level, + "device_id": door.device_id, + "door_number": door.door_number, + "name": door.name, + "status": door.status, + "link_status": door.link_status, + "battery_level": door.battery_level, } - for uid, coordinator in config_entry.runtime_data.items() + for uid, door in config_entry.runtime_data.data.items() }, } diff --git a/homeassistant/components/aladdin_connect/entity.py b/homeassistant/components/aladdin_connect/entity.py index 39a38fbd1ca2..eff536a6b1fa 100644 --- a/homeassistant/components/aladdin_connect/entity.py +++ b/homeassistant/components/aladdin_connect/entity.py @@ -1,6 +1,7 @@ """Base class for Aladdin Connect entities.""" from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: AladdinConnectCoordinator) -> None: + def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None: """Initialize Aladdin Connect entity.""" super().__init__(coordinator) - device = coordinator.data + self._door_id = door_id + door = self.door self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.unique_id)}, + identifiers={(DOMAIN, door.unique_id)}, manufacturer="Aladdin Connect", - name=device.name, + name=door.name, ) - self._device_id = device.device_id - self._number = device.door_number + self._device_id = door.device_id + self._number = door.door_number + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._door_id in self.coordinator.data + + @property + def door(self) -> GarageDoor: + """Return the garage door data.""" + return self.coordinator.data[self._door_id] @property def client(self) -> AladdinConnectClient: diff --git a/homeassistant/components/aladdin_connect/quality_scale.yaml b/homeassistant/components/aladdin_connect/quality_scale.yaml index 1f813007338f..715ed2c3c06c 100644 --- a/homeassistant/components/aladdin_connect/quality_scale.yaml +++ b/homeassistant/components/aladdin_connect/quality_scale.yaml @@ -57,7 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index d8d286b00724..45943327ad42 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator @@ -49,13 +49,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Aladdin Connect sensor devices.""" - coordinators = entry.runtime_data + coordinator = entry.runtime_data + known_devices: set[str] = set() - async_add_entities( - AladdinConnectSensor(coordinator, description) - for coordinator in coordinators.values() - for description in SENSOR_TYPES - ) + @callback + def _async_add_new_devices() -> None: + """Detect and add entities for new doors.""" + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AladdinConnectSensor(coordinator, door_id, description) + for door_id in new_devices + for description in SENSOR_TYPES + ) + + _async_add_new_devices() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): @@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): def __init__( self, coordinator: AladdinConnectCoordinator, + door_id: str, entity_description: AladdinConnectSensorEntityDescription, ) -> None: """Initialize the Aladdin Connect sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, door_id) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}" + self._attr_unique_id = f"{door_id}-{entity_description.key}" @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.door) diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index 173c07363718..830994db4f17 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -125,7 +125,7 @@ async def test_cover_unavailable( assert state is not None assert state.state != STATE_UNAVAILABLE - mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError() + mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError() freezer.tick(15) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 27fd112e9391..d9f0675e2282 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -5,16 +5,17 @@ from unittest.mock import AsyncMock, patch from aiohttp import ClientConnectionError, RequestInfo from aiohttp.client_exceptions import ClientResponseError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.aladdin_connect import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_entry( @@ -137,3 +138,49 @@ async def test_remove_stale_devices( ) assert len(device_entries) == 1 assert device_entries[0].identifiers == {(DOMAIN, "test_device_id-1")} + + +async def test_dynamic_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_aladdin_connect_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are automatically discovered on coordinator refresh.""" + await init_integration(hass, mock_config_entry) + + # Initially one door -> one cover entity + one sensor entity + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 1 + assert hass.states.get("cover.test_door") is not None + + # Simulate a new door appearing on the API + mock_door_2 = AsyncMock() + mock_door_2.device_id = "test_device_id_2" + mock_door_2.door_number = 1 + mock_door_2.name = "Test Door 2" + mock_door_2.status = "open" + mock_door_2.link_status = "connected" + mock_door_2.battery_level = 80 + mock_door_2.unique_id = f"{mock_door_2.device_id}-{mock_door_2.door_number}" + + existing_door = mock_aladdin_connect_api.get_doors.return_value[0] + mock_aladdin_connect_api.get_doors.return_value = [existing_door, mock_door_2] + + # Trigger coordinator refresh + freezer.tick(15) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now two devices should exist + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 2 + + # New cover entity should exist + assert hass.states.get("cover.test_door_2") is not None diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py index 7f0b9a260162..41c9945ddbd1 100644 --- a/tests/components/aladdin_connect/test_sensor.py +++ b/tests/components/aladdin_connect/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensor_unavailable( assert state is not None assert state.state != STATE_UNAVAILABLE - mock_aladdin_connect_api.update_door.side_effect = aiohttp.ClientError() + mock_aladdin_connect_api.get_doors.side_effect = aiohttp.ClientError() freezer.tick(15) async_fire_time_changed(hass) await hass.async_block_till_done()