Fix via_device race in elkm1 (#177714)

This commit is contained in:
Erik Montnemery
2026-08-01 13:03:09 +02:00
committed by GitHub
parent 23a066bf5c
commit 7c39b89171
3 changed files with 127 additions and 15 deletions
+9 -1
View File
@@ -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
+26 -14
View File
@@ -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)
+92
View File
@@ -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