Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
973fccfe24 Add migration for Overkiz sub-device entities
Previously, when the Overkiz integration was upgraded to create separate
devices for sub-devices (thermostats, water heaters, etc.), existing
entities remained associated with the parent device due to cached device
registry entries.

This migration automatically moves sub-device entities to their correct
new devices by:
1. Identifying entities with #N suffix in their unique_id (where N != 1)
2. Creating the appropriate sub-device if it doesn't exist
3. Updating the entity registry to associate with the new sub-device

This ensures users get the correct device hierarchy without needing to
manually remove and re-add the integration.

https://claude.ai/code/session_01DAMmdB6bjeSAoi2xc6dKdF
2026-02-04 21:29:28 +00:00
Claude
8bbc1e1b62 Fix device removal for Overkiz sub-devices
Update the device removal handler to look up devices by their full
device URL first (for sub-devices), then fall back to base URL
(for parent devices). This fixes device removal when sub-devices
have their own unique identifiers.

https://claude.ai/code/session_01DAMmdB6bjeSAoi2xc6dKdF
2026-02-04 21:20:52 +00:00
Claude
93b02a23d0 Create separate sub-devices for Overkiz heat pump zones
Previously, sub-devices (thermostats, water heaters) in Overkiz
integrations were grouped under the parent device using the same
device identifier. This change creates separate device entries for
each sub-device, linked to the parent via via_device.

This improves the device hierarchy in Home Assistant, making it
easier to identify and manage individual components of multi-zone
heat pumps and similar composite devices.

https://claude.ai/code/session_01DAMmdB6bjeSAoi2xc6dKdF
2026-02-04 20:21:58 +00:00
4 changed files with 250 additions and 16 deletions

View File

@@ -184,6 +184,41 @@ async def _async_migrate_entries(
) -> bool:
"""Migrate old entries to new unique IDs."""
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
# Migrate sub-device entities to their own devices
# Previously, sub-devices (URL#2, #3, etc.) shared the same device as the parent
# Now, each sub-device gets its own device entry
for entity_entry in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
):
unique_id = entity_entry.unique_id
# Check if this is a sub-device entity (has #N suffix where N != 1)
if "#" in unique_id:
# Extract the device URL part (before any entity-specific suffix like "-sensor_key")
device_url_part = unique_id.split("-")[0] if "-" in unique_id else unique_id
if "#" in device_url_part and not device_url_part.endswith("#1"):
# This is a sub-device entity
base_device_url = device_url_part.split("#")[0]
# Check if this entity is still associated with the parent device
if entity_entry.device_id:
parent_device = device_registry.async_get(entity_entry.device_id)
if parent_device and (DOMAIN, base_device_url) in parent_device.identifiers:
# Entity is still on parent device, create/get sub-device and move it
sub_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, device_url_part)},
via_device=(DOMAIN, base_device_url),
)
LOGGER.debug(
"Migrating entity '%s' from parent device to sub-device '%s'",
entity_entry.entity_id,
device_url_part,
)
entity_registry.async_update_entity(
entity_entry.entity_id, device_id=sub_device.id
)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:

View File

@@ -192,16 +192,23 @@ async def on_device_removed(
if not event.device_url:
return
base_device_url = event.device_url.split("#")[0]
registry = dr.async_get(coordinator.hass)
if registered_device := registry.async_get_device(
identifiers={(DOMAIN, base_device_url)}
):
# Try to find device by full device URL first (for sub-devices)
# then fall back to base URL (for parent devices)
registered_device = registry.async_get_device(
identifiers={(DOMAIN, event.device_url)}
)
if not registered_device:
base_device_url = event.device_url.split("#")[0]
registered_device = registry.async_get_device(
identifiers={(DOMAIN, base_device_url)}
)
if registered_device:
registry.async_remove_device(registered_device.id)
if event.device_url:
del coordinator.devices[event.device_url]
del coordinator.devices[event.device_url]
@EVENT_HANDLERS.register(EventName.EXECUTION_REGISTERED)

View File

@@ -60,16 +60,6 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
def generate_device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
# Some devices, such as the Smart Thermostat have several devices
# in one physical device, with same device url, terminated by '#' and a number.
# In this case, we use the base device url as the device identifier.
if self.is_sub_device:
# Only return the url of the base device, to inherit device name
# and model from parent device.
return DeviceInfo(
identifiers={(DOMAIN, self.executor.base_device_url)},
)
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
@@ -91,6 +81,20 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
else None
)
# Some devices, such as the Smart Thermostat have several devices
# in one physical device, with same device url, terminated by '#' and a number.
# Sub-devices are linked to the parent device via via_device.
if self.is_sub_device:
return DeviceInfo(
identifiers={(DOMAIN, self.device_url)},
name=self.device.label,
manufacturer=str(manufacturer),
model=str(model),
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.base_device_url),
configuration_url=self.coordinator.client.server.configuration_url,
)
return DeviceInfo(
identifiers={(DOMAIN, self.executor.base_device_url)},
name=self.device.label,

View File

@@ -0,0 +1,188 @@
"""Tests for Overkiz entity module."""
from unittest.mock import MagicMock, patch
from pyoverkiz.enums import UIClass, UIWidget
from pyoverkiz.models import Device
import pytest
from homeassistant.components.overkiz.const import DOMAIN
from homeassistant.components.overkiz.entity import OverkizEntity
@pytest.fixture
def mock_coordinator():
"""Create a mock coordinator."""
coordinator = MagicMock()
coordinator.client.server.manufacturer = "Test Manufacturer"
coordinator.client.server.configuration_url = "https://example.com"
coordinator.areas = None
return coordinator
def create_mock_device(
device_url: str,
label: str = "Test Device",
controllable_name: str = "test:Component",
) -> Device:
"""Create a mock device."""
device = MagicMock(spec=Device)
device.device_url = device_url
device.label = label
device.controllable_name = controllable_name
device.widget = UIWidget.UNKNOWN
device.ui_class = UIClass.GENERIC
device.place_oid = None
device.states = {}
device.attributes = {}
device.available = True
return device
class TestOverkizEntityDeviceInfo:
"""Test device info generation for Overkiz entities."""
def test_parent_device_uses_base_url_as_identifier(
self, mock_coordinator: MagicMock
) -> None:
"""Test parent device (#1) uses base URL as identifier."""
device_url = "io://1234-5678-1234/device#1"
device = create_mock_device(device_url, "Main Device")
mock_coordinator.data = {device_url: device}
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = device_url
entity.base_device_url = "io://1234-5678-1234/device"
entity.coordinator = mock_coordinator
entity.executor = MagicMock()
entity.executor.base_device_url = "io://1234-5678-1234/device"
entity.executor.get_gateway_id.return_value = "1234-5678-1234"
entity.executor.select_attribute.return_value = None
entity.executor.select_state.return_value = None
device_info = entity.generate_device_info()
assert device_info["identifiers"] == {(DOMAIN, "io://1234-5678-1234/device")}
assert device_info["via_device"] == (DOMAIN, "1234-5678-1234")
def test_sub_device_uses_full_url_as_identifier(
self, mock_coordinator: MagicMock
) -> None:
"""Test sub-device (#2, #3, etc.) uses full URL as identifier."""
device_url = "io://1234-5678-1234/device#2"
device = create_mock_device(device_url, "Zone 1 Thermostat")
mock_coordinator.data = {device_url: device}
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = device_url
entity.base_device_url = "io://1234-5678-1234/device"
entity.coordinator = mock_coordinator
entity.executor = MagicMock()
entity.executor.base_device_url = "io://1234-5678-1234/device"
entity.executor.get_gateway_id.return_value = "1234-5678-1234"
entity.executor.select_attribute.return_value = None
entity.executor.select_state.return_value = None
device_info = entity.generate_device_info()
# Sub-device should have its own unique identifier
assert device_info["identifiers"] == {(DOMAIN, "io://1234-5678-1234/device#2")}
# Sub-device should link to parent device via via_device
assert device_info["via_device"] == (
DOMAIN,
"io://1234-5678-1234/device",
)
assert device_info["name"] == "Zone 1 Thermostat"
def test_multiple_sub_devices_have_unique_identifiers(
self, mock_coordinator: MagicMock
) -> None:
"""Test multiple sub-devices each have unique identifiers."""
base_url = "io://1234-5678-1234/device"
device_urls = [f"{base_url}#2", f"{base_url}#3", f"{base_url}#4"]
devices = {
url: create_mock_device(url, f"Device {i}")
for i, url in enumerate(device_urls, start=2)
}
mock_coordinator.data = devices
device_infos = []
for device_url in device_urls:
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = device_url
entity.base_device_url = base_url
entity.coordinator = mock_coordinator
entity.executor = MagicMock()
entity.executor.base_device_url = base_url
entity.executor.get_gateway_id.return_value = "1234-5678-1234"
entity.executor.select_attribute.return_value = None
entity.executor.select_state.return_value = None
device_infos.append(entity.generate_device_info())
# Each sub-device should have unique identifiers
identifiers = [info["identifiers"] for info in device_infos]
assert len(identifiers) == len({tuple(sorted(i)) for i in identifiers})
# All sub-devices should link to the same parent
for device_info in device_infos:
assert device_info["via_device"] == (DOMAIN, base_url)
def test_device_without_hash_uses_url_as_identifier(
self, mock_coordinator: MagicMock
) -> None:
"""Test device without # suffix uses URL as identifier."""
device_url = "io://1234-5678-1234/simple_device"
device = create_mock_device(device_url, "Simple Device")
mock_coordinator.data = {device_url: device}
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = device_url
entity.base_device_url = device_url
entity.coordinator = mock_coordinator
entity.executor = MagicMock()
entity.executor.base_device_url = device_url
entity.executor.get_gateway_id.return_value = "1234-5678-1234"
entity.executor.select_attribute.return_value = None
entity.executor.select_state.return_value = None
device_info = entity.generate_device_info()
assert device_info["identifiers"] == {
(DOMAIN, "io://1234-5678-1234/simple_device")
}
# Non-sub-device links to gateway
assert device_info["via_device"] == (DOMAIN, "1234-5678-1234")
class TestIsSubDevice:
"""Test is_sub_device property."""
def test_device_with_hash_1_is_not_sub_device(self) -> None:
"""Test device ending with #1 is not considered a sub-device."""
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = "io://1234-5678-1234/device#1"
assert entity.is_sub_device is False
def test_device_with_hash_2_is_sub_device(self) -> None:
"""Test device ending with #2 is considered a sub-device."""
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = "io://1234-5678-1234/device#2"
assert entity.is_sub_device is True
def test_device_without_hash_is_not_sub_device(self) -> None:
"""Test device without # is not considered a sub-device."""
with patch.object(OverkizEntity, "__init__", lambda x, y, z: None):
entity = OverkizEntity.__new__(OverkizEntity)
entity.device_url = "io://1234-5678-1234/device"
assert entity.is_sub_device is False