From 4d565e6089efde7f436781b3fe215ec6099aa1b8 Mon Sep 17 00:00:00 2001 From: jorgenvi <59606957+jorgenvi@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:45:55 +0100 Subject: [PATCH] Fix device registry collisions for multi-module Touchline SL setups (#166414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jørgen Vinne Iversen Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joostlek --- .../components/touchline_sl/__init__.py | 48 +++++++++++++ .../components/touchline_sl/entity.py | 2 +- tests/components/touchline_sl/test_climate.py | 70 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py index 6f81b5b26e09..804aaa46c721 100644 --- a/homeassistant/components/touchline_sl/__init__.py +++ b/homeassistant/components/touchline_sl/__init__.py @@ -16,6 +16,52 @@ from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +def _migrate_device_identifiers( + entry: TouchlineSLConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Migrate zone device identifiers from zone-only to module-aware format. + + Previously, zone devices used (DOMAIN, str(zone_id)) as their identifier. + This caused collisions when multiple modules had zones with the same ID. + The new format is (DOMAIN, f"{module_id}-{zone_id}"). + """ + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + for identifier_domain, identifier in device.identifiers: + if identifier_domain != DOMAIN: + continue + # Skip identifiers that already include a module prefix (new format) + if "-" in identifier: + continue + + # Resolve the module device via via_device_id + if device.via_device_id is None: + break + + module_device = device_registry.async_get(device.via_device_id) + if module_device is None: + break + + module_id: str | None = None + for module_domain, module_identifier in module_device.identifiers: + if module_domain == DOMAIN: + module_id = module_identifier + break + + if module_id is None: + break + + # Preserve other identifiers and replace only the legacy one + updated_identifiers = set(device.identifiers) + updated_identifiers.discard((DOMAIN, identifier)) + updated_identifiers.add((DOMAIN, f"{module_id}-{identifier}")) + device_registry.async_update_device( + device.id, + new_identifiers=updated_identifiers, + ) + break + + async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) -> bool: """Set up Roth Touchline SL from a config entry.""" account = TouchlineSL( @@ -36,6 +82,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) device_registry = dr.async_get(hass) + _migrate_device_identifiers(entry, device_registry) + # Create a new Device for each coorodinator to represent each module for c in coordinators: module = c.data.module diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py index 773ba6dfef75..2e3faa36132e 100644 --- a/homeassistant/components/touchline_sl/entity.py +++ b/homeassistant/components/touchline_sl/entity.py @@ -19,7 +19,7 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]): super().__init__(coordinator) self.zone_id = zone_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(zone_id))}, + identifiers={(DOMAIN, f"{coordinator.data.module.id}-{zone_id}")}, name=self.zone.name, manufacturer="Roth", via_device=(DOMAIN, coordinator.data.module.id), diff --git a/tests/components/touchline_sl/test_climate.py b/tests/components/touchline_sl/test_climate.py index 94d50364ff81..d85e21458f20 100644 --- a/tests/components/touchline_sl/test_climate.py +++ b/tests/components/touchline_sl/test_climate.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock, MagicMock import pytest from homeassistant.components.climate import HVACMode +from homeassistant.components.touchline_sl import DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import make_mock_module, make_mock_zone @@ -53,3 +55,71 @@ async def test_climate_zone_unavailable_on_alarm( state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_UNAVAILABLE + + +async def test_zones_with_same_id_across_modules_get_distinct_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that identical zone IDs on different modules produce separate devices.""" + zone_a = make_mock_zone(zone_id=1, name="Zone 1") + zone_b = make_mock_zone(zone_id=1, name="Zone 1") + + module_a = make_mock_module([zone_a]) + module_a.id = "module-aaa" + module_b = make_mock_module([zone_b]) + module_b.id = "module-bbb" + + mock_touchlinesl_client.modules = AsyncMock(return_value=[module_a, module_b]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_a = device_registry.async_get_device(identifiers={(DOMAIN, "module-aaa-1")}) + device_b = device_registry.async_get_device(identifiers={(DOMAIN, "module-bbb-1")}) + + assert device_a is not None + assert device_b is not None + assert device_a.id != device_b.id + + +async def test_migrate_device_identifiers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_touchlinesl_client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that existing zone devices with old-style identifiers are migrated.""" + zone = make_mock_zone(zone_id=1) + module = make_mock_module([zone]) + mock_touchlinesl_client.modules = AsyncMock(return_value=[module]) + + # Pre-populate the device registry with the old identifier format. + mock_config_entry.add_to_hass(hass) + + # Create the module device as it would have existed in a previous run. + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "deadbeef")}, + ) + + # Create the legacy zone device with via_device pointing to the module. + old_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "1")}, + via_device=(DOMAIN, "deadbeef"), + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Old identifier should no longer exist. + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) is None + + # New identifier should exist and point to the same device entry. + migrated = device_registry.async_get_device(identifiers={(DOMAIN, "deadbeef-1")}) + assert migrated is not None + assert migrated.id == old_device.id