Fix device registry collisions for multi-module Touchline SL setups (#166414)

Co-authored-by: Jørgen Vinne Iversen <jorgenvi@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-03-25 19:45:55 +01:00
1 parent faaa87e36f
commit 4d565e6089
3 files changed
+119 -1

No files matched your search

@@ -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
@@ -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),
@@ -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