From eccb5cac1ddf246ceb19edcf359febc18c8a4760 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2026 07:52:30 +0200 Subject: [PATCH] Fix via_device race in isy994 (#177722) --- homeassistant/components/isy994/__init__.py | 32 +++++++++++++++------ homeassistant/components/isy994/helpers.py | 13 ++++++--- tests/components/isy994/test_init.py | 30 +++++++++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 938a53e59584..b7cdb9c6f925 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -138,12 +138,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: ) from err isy_data = entry.runtime_data = IsyData() - _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) + isy_data.root = isy + _async_get_or_create_isy_device_in_registry(hass, entry, isy) + via_device_id = dr.async_get_device_id_by_identifier( + hass, (DOMAIN, isy.uuid), config_entry_id=entry.entry_id + ) + _categorize_nodes( + isy_data, isy.nodes, ignore_identifier, sensor_identifier, via_device_id + ) _categorize_programs(isy_data, isy.programs) # Gather ISY Variables to be added. if isy.variables.children: isy_data.devices[CONF_VARIABLES] = _create_service_device_info( - isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES + isy, + name=CONF_VARIABLES.title(), + unique_id=CONF_VARIABLES, + via_device_id=via_device_id, ) numbers = isy_data.variables[Platform.NUMBER] for vtype, _, vid in isy.variables.children: @@ -152,7 +162,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL) ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( - isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK + isy, + name=CONFIG_NETWORKING, + unique_id=CONF_NETWORK, + via_device_id=via_device_id, ) for resource in isy.networking.nobjs: isy_data.net_resources.append(resource) @@ -160,9 +173,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs LOGGER.debug(repr(isy.clock)) - isy_data.root = isy - _async_get_or_create_isy_device_in_registry(hass, entry, isy) - # Load platforms for the devices in the ISY controller that we support. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -202,9 +212,11 @@ def _async_get_or_create_isy_device_in_registry( ) -def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceInfo: +def _create_service_device_info( + isy: ISY, name: str, unique_id: str, via_device_id: str | None +) -> DeviceInfo: """Create device info for ISY service devices.""" - return DeviceInfo( + device_info = DeviceInfo( identifiers={ ( DOMAIN, @@ -216,9 +228,11 @@ def _create_service_device_info(isy: ISY, name: str, unique_id: str) -> DeviceIn model=isy.conf[ISY_CONF_MODEL], sw_version=isy.conf[ISY_CONF_FIRMWARE], configuration_url=isy.conn.url, - via_device=(DOMAIN, isy.uuid), entry_type=DeviceEntryType.SERVICE, ) + if via_device_id is not None: + device_info["via_device_id"] = via_device_id + return device_info async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 9da04c45a254..ebd9a38e1347 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -288,17 +288,18 @@ def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None: isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT)) -def _generate_device_info(node: Node) -> DeviceInfo: +def _generate_device_info(node: Node, via_device_id: str | None) -> DeviceInfo: """Generate the device info for a root node device.""" isy = node.isy device_info = DeviceInfo( identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")}, manufacturer=node.protocol.title(), name=node.name, - via_device=(DOMAIN, isy.uuid), configuration_url=isy.conn.url, suggested_area=node.folder, ) + if via_device_id is not None: + device_info["via_device_id"] = via_device_id # ISYv5 Device Types can provide model and manufacturer model: str = str(node.address).rpartition(" ")[0] or node.address @@ -328,7 +329,11 @@ def _generate_device_info(node: Node) -> DeviceInfo: def _categorize_nodes( - isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str + isy_data: IsyData, + nodes: Nodes, + ignore_identifier: str, + sensor_identifier: str, + via_device_id: str | None, ) -> None: """Sort the nodes to their proper platforms.""" for path, node in nodes: @@ -339,7 +344,7 @@ def _categorize_nodes( if hasattr(node, "parent_node") and node.parent_node is None: # This is a physical device / parent node - isy_data.devices[node.address] = _generate_device_info(node) + isy_data.devices[node.address] = _generate_device_info(node, via_device_id) isy_data.root_nodes[Platform.BUTTON].append(node) # Any parent node can have communication errors: isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR)) diff --git a/tests/components/isy994/test_init.py b/tests/components/isy994/test_init.py index 2cd003ce5d83..59d724cb43fc 100644 --- a/tests/components/isy994/test_init.py +++ b/tests/components/isy994/test_init.py @@ -1,5 +1,7 @@ """Test the Universal Devices ISY/IoX integration init.""" +from collections.abc import Callable +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -8,6 +10,7 @@ from homeassistant.components.isy994.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -71,3 +74,30 @@ async def test_setup_forwards_verify_ssl_to_pyisy( assert entry.state is ConfigEntryState.LOADED assert isy_constructor.call_args.kwargs["verify_ssl"] is verify_ssl + + +async def test_node_device_linked_to_isy_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_isy: MagicMock, + mock_node: Callable[..., Any], +) -> None: + """Test a root node's device is linked to the ISY device via via_device_id.""" + mock_config_entry.add_to_hass(hass) + + node = mock_node(mock_isy, "22 22 22 1", "Test Node", "GenericNode") + mock_isy.nodes.__iter__.return_value = [("Test Node", node)] + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + isy_device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_isy.uuid), mock_config_entry.entry_id + ) + node_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_isy.uuid}_{node.address}"), mock_config_entry.entry_id + ) + assert isy_device is not None + assert node_device is not None + assert node_device.via_device_id == isy_device.id