mirror of
https://github.com/home-assistant/core.git
synced 2026-04-07 07:56:23 +00:00
Compare commits
3 Commits
bump/pytho
...
claude/ove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973fccfe24 | ||
|
|
8bbc1e1b62 | ||
|
|
93b02a23d0 |
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
188
tests/components/overkiz/test_entity.py
Normal file
188
tests/components/overkiz/test_entity.py
Normal 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
|
||||
Reference in New Issue
Block a user