mirror of
https://github.com/home-assistant/core.git
synced 2026-10-05 16:37:35 +01:00
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:
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
|
||||
Reference in new issue
Block a user