From 7c39b891710d7802afdcee8398fd314744232237 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 1 Aug 2026 13:03:09 +0200 Subject: [PATCH] Fix via_device race in elkm1 (#177714) --- homeassistant/components/elkm1/__init__.py | 10 ++- homeassistant/components/elkm1/entity.py | 40 ++++++---- tests/components/elkm1/test_init.py | 92 ++++++++++++++++++++++ 3 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 tests/components/elkm1/test_init.py diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 712e5206f410..74774a09de20 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.network import is_ip_address @@ -58,6 +58,7 @@ from .discovery import ( async_trigger_discovery, async_update_entry_from_discovery, ) +from .entity import create_elk_system_device_info from .models import ELKM1Data from .services import async_setup_services @@ -308,6 +309,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo keypads={}, ) + # Register the ElkM1 system device before forwarding platforms so entities + # on any platform can deterministically resolve it as their via_device. + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **create_elk_system_device_info(elk, prefix, entry.unique_id), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py index a9c4b6494cc1..b2fc9fcfd981 100644 --- a/homeassistant/components/elkm1/entity.py +++ b/homeassistant/components/elkm1/entity.py @@ -10,6 +10,7 @@ from elkm1_lib.elk import Elk from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -47,6 +48,23 @@ def create_elk_entities( return entities +def create_elk_system_device_info(elk: Elk, prefix: str, mac: str | None) -> DeviceInfo: + """Return the device info for the ElkM1 system device.""" + device_name = "ElkM1" + if prefix: + device_name += f" {prefix}" + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{prefix}_system")}, + manufacturer="ELK Products, Inc.", + model="M1", + name=device_name, + sw_version=elk.panel.elkm1_version, + ) + if mac: + device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} + return device_info + + def generate_unique_id(prefix: str, element: Element) -> str: """Generate a unique id.""" # unique_id starts with elkm1_ iff there is no prefix @@ -128,10 +146,16 @@ class ElkEntity(Entity): @override def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" + config_entry = self.platform.config_entry + assert config_entry return DeviceInfo( name=self._element.name, identifiers={(DOMAIN, self._unique_id)}, - via_device=(DOMAIN, f"{self._prefix}_system"), + via_device_id=dr.async_get_device_id_by_identifier( + self.hass, + (DOMAIN, f"{self._prefix}_system"), + config_entry_id=config_entry.entry_id, + ), ) @@ -142,16 +166,4 @@ class ElkAttachedEntity(ElkEntity): @override def device_info(self) -> DeviceInfo: """Device info for the underlying ElkM1 system.""" - device_name = "ElkM1" - if self._prefix: - device_name += f" {self._prefix}" - device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._prefix}_system")}, - manufacturer="ELK Products, Inc.", - model="M1", - name=device_name, - sw_version=self._elk.panel.elkm1_version, - ) - if self._mac: - device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)} - return device_info + return create_elk_system_device_info(self._elk, self._prefix, self._mac) diff --git a/tests/components/elkm1/test_init.py b/tests/components/elkm1/test_init.py new file mode 100644 index 000000000000..970725a4238c --- /dev/null +++ b/tests/components/elkm1/test_init.py @@ -0,0 +1,92 @@ +"""Tests for the Elk-M1 Control init.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components.elkm1.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_MAC, _patch_discovery, mock_elk + +from tests.common import MockConfigEntry + + +def _mocked_elk_with_light() -> MagicMock: + """Return a mocked Elk that exposes a single PLC light and nothing else.""" + light = MagicMock() + light.index = 0 + light.name = "Test Light" + light.default_name.return_value = "test_light" + light.configured = True + light.status = 0 + light.as_dict.return_value = {} + + elk = mock_elk(sync_complete=True) + elk.is_connected.return_value = True + for collection in ( + "areas", + "tasks", + "counters", + "keypads", + "zones", + "outputs", + "settings", + "thermostats", + ): + setattr(elk, collection, []) + elk.lights = [light] + # The panel sensor is an attached entity; skip it so the only registered + # device besides the system device is the light's own (via_device) device. + elk.panel.configured = False + elk.panel.elkm1_version = "1.0.0" + elk.panel.temperature_units = "F" + return elk + + +async def test_light_via_device_links_to_system_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A child (light) device links to the system device registered at setup. + + With auto configure and only a light present, no sibling attached entity + creates the system device, so the link resolves only because setup + registers the system device before platforms are forwarded. + """ + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "elks://1.2.3.4", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PREFIX: "", + "auto_configure": True, + }, + unique_id=MOCK_MAC, + ) + config_entry.add_to_hass(hass) + + with ( + _patch_discovery(), + patch( + "homeassistant.components.elkm1.Elk", + return_value=_mocked_elk_with_light(), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "_system"), config_entry.entry_id + ) + assert system_device is not None + assert system_device.name == "ElkM1" + + light_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "elkm1_test_light"), config_entry.entry_id + ) + assert light_device is not None + assert light_device.via_device_id == system_device.id