From bd58dbc207364522a6bddf620ed8a2f2df9bb147 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 13:06:50 +0200 Subject: [PATCH] Fix via_device race in yale_smart_alarm (#177858) --- .../components/yale_smart_alarm/__init__.py | 10 ++++- .../yale_smart_alarm/coordinator.py | 23 ++++++++++- .../components/yale_smart_alarm/entity.py | 25 ++++++------ .../components/yale_smart_alarm/test_init.py | 38 ++++++++++++++++++- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index ec410077fe17..d586bf027c75 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -4,7 +4,7 @@ from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator @@ -19,6 +19,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # Register the alarm panel device so child devices can link to it via + # via_device_id, since the panel is otherwise only registered by a sibling + # entity on a concurrently loaded platform. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **coordinator.device_info, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index f8cd3d726eb1..085cd2298a5c 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -10,12 +10,20 @@ from yalesmartalarmclient.exceptions import AuthenticationError from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import YaleConfigEntry -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + LOGGER, + MANUFACTURER, + MODEL, + YALE_BASE_ERRORS, +) class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -36,6 +44,19 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.locks: list[YaleLock] = [] + @property + def device_info(self) -> DeviceInfo: + """Return device info for the alarm panel.""" + panel_info = self.data["panel_info"] + return DeviceInfo( + identifiers={(DOMAIN, self.config_entry.data[CONF_USERNAME])}, + connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, + manufacturer=MANUFACTURER, + model=MODEL, + name=self.config_entry.title, + sw_version=panel_info["version"], + ) + def _yale_setup(self) -> tuple[YaleSmartAlarmClient, list[YaleLock]]: """Set up connection to Yale.""" yale = YaleSmartAlarmClient( diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index 2610f54f0a90..5bbe78c5a4fb 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -3,7 +3,8 @@ from yalesmartalarmclient import YaleLock from homeassistant.const import CONF_USERNAME -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +26,11 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, data["address"])}, - via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) @@ -43,7 +48,11 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, lock.sid())}, - via_device=(DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, coordinator.config_entry.data[CONF_USERNAME]), + config_entry_id=coordinator.config_entry.entry_id, + ), ) self.lock_data = lock @@ -56,12 +65,4 @@ class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize an Yale device.""" super().__init__(coordinator) - panel_info = coordinator.data["panel_info"] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.data[CONF_USERNAME])}, - manufacturer=MANUFACTURER, - model=MODEL, - name=coordinator.config_entry.title, - connections={(CONNECTION_NETWORK_MAC, panel_info["mac"])}, - sw_version=panel_info["version"], - ) + self._attr_device_info = coordinator.device_info diff --git a/tests/components/yale_smart_alarm/test_init.py b/tests/components/yale_smart_alarm/test_init.py index c2926e857ce5..dc438387d47b 100644 --- a/tests/components/yale_smart_alarm/test_init.py +++ b/tests/components/yale_smart_alarm/test_init.py @@ -2,11 +2,14 @@ from unittest.mock import Mock, patch +import pytest + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.yale_smart_alarm.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG @@ -44,6 +47,39 @@ async def test_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("load_platforms", "child_identifier"), + [ + pytest.param([Platform.LOCK], "1111", id="lock"), + pytest.param([Platform.BINARY_SENSOR], "RF4", id="binary_sensor"), + ], +) +async def test_child_devices_linked_to_panel( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + device_registry: dr.DeviceRegistry, + child_identifier: str, +) -> None: + """Test child devices link to the alarm panel through via_device_id. + + Only a single platform is loaded per case, whose entities never create the + panel device themselves, so the link is established solely by the panel + being registered at setup. Covers both YaleEntity and YaleLockEntity. + """ + config_entry, _ = load_config_entry + + panel_device = device_registry.async_get_device_by_identifier( + (DOMAIN, ENTRY_CONFIG["username"]), config_entry.entry_id + ) + assert panel_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, child_identifier), config_entry.entry_id + ) + assert child_device is not None + assert child_device.via_device_id == panel_device.id + + async def test_migrate_entry( hass: HomeAssistant, get_client: Mock,