mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 17:27:10 +00:00
Add subdevices support to ESPHome (#147343)
This commit is contained in:
parent
58e60fdfac
commit
0a884c7253
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import functools
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast
|
||||
|
||||
@ -13,7 +14,6 @@ from aioesphomeapi import (
|
||||
EntityCategory as EsphomeEntityCategory,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
build_unique_id,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@ -24,6 +24,7 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@ -32,9 +33,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
|
||||
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
|
||||
_StateT = TypeVar("_StateT", bound=EntityState)
|
||||
@ -53,21 +56,74 @@ def async_static_info_updated(
|
||||
) -> None:
|
||||
"""Update entities of this platform when entities are listed."""
|
||||
current_infos = entry_data.info[info_type]
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
new_infos: dict[int, EntityInfo] = {}
|
||||
add_entities: list[_EntityT] = []
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
for info in infos:
|
||||
if not current_infos.pop(info.key, None):
|
||||
# Create new entity
|
||||
new_infos[info.key] = info
|
||||
|
||||
# Create new entity if it doesn't exist
|
||||
if not (old_info := current_infos.pop(info.key, None)):
|
||||
entity = entity_type(entry_data, platform.domain, info, state_type)
|
||||
add_entities.append(entity)
|
||||
new_infos[info.key] = info
|
||||
continue
|
||||
|
||||
# Entity exists - check if device_id has changed
|
||||
if old_info.device_id == info.device_id:
|
||||
continue
|
||||
|
||||
# Entity has switched devices, need to migrate unique_id
|
||||
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
|
||||
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
|
||||
|
||||
# If entity not found in registry, re-add it
|
||||
# This happens when the device_id changed and the old device was deleted
|
||||
if entity_id is None:
|
||||
_LOGGER.info(
|
||||
"Entity with old unique_id %s not found in registry after device_id "
|
||||
"changed from %s to %s, re-adding entity",
|
||||
old_unique_id,
|
||||
old_info.device_id,
|
||||
info.device_id,
|
||||
)
|
||||
entity = entity_type(entry_data, platform.domain, info, state_type)
|
||||
add_entities.append(entity)
|
||||
continue
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
new_unique_id = build_device_unique_id(device_info.mac_address, info)
|
||||
|
||||
# Update unique_id if it changed
|
||||
if old_unique_id != new_unique_id:
|
||||
updates["new_unique_id"] = new_unique_id
|
||||
|
||||
# Update device assignment
|
||||
if info.device_id:
|
||||
# Entity now belongs to a sub device
|
||||
new_device = dev_reg.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
|
||||
)
|
||||
else:
|
||||
# Entity now belongs to the main device
|
||||
new_device = dev_reg.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
if new_device:
|
||||
updates["device_id"] = new_device.id
|
||||
|
||||
# Apply all updates at once
|
||||
if updates:
|
||||
ent_reg.async_update_entity(entity_id, **updates)
|
||||
|
||||
# Anything still in current_infos is now gone
|
||||
if current_infos:
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
entry_data.async_remove_entities(
|
||||
hass, current_infos.values(), device_info.mac_address
|
||||
)
|
||||
@ -244,11 +300,28 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
self._key = entity_info.key
|
||||
self._state_type = state_type
|
||||
self._on_static_info_update(entity_info)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
device_name = device_info.name
|
||||
# Determine the device connection based on whether this entity belongs to a sub device
|
||||
if entity_info.device_id:
|
||||
# Entity belongs to a sub device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
|
||||
}
|
||||
)
|
||||
# Use the pre-computed device_id_to_name mapping for O(1) lookup
|
||||
device_name = entry_data.device_id_to_name.get(
|
||||
entity_info.device_id, device_info.name
|
||||
)
|
||||
else:
|
||||
# Entity belongs to the main device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
if entity_info.name:
|
||||
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
|
||||
self.entity_id = f"{domain}.{device_name}_{entity_info.object_id}"
|
||||
else:
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If name is not set, ESPHome will use the sanitized friendly name
|
||||
@ -256,7 +329,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
# as the entity_id before it is sanitized since the sanitizer
|
||||
# is not utf-8 aware. In this case, its always going to be
|
||||
# an empty string so we drop the object_id.
|
||||
self.entity_id = f"{domain}.{device_info.name}"
|
||||
self.entity_id = f"{domain}.{device_name}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@ -290,7 +363,9 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
static_info = cast(_InfoT, static_info)
|
||||
assert device_info
|
||||
self._static_info = static_info
|
||||
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
|
||||
self._attr_unique_id = build_device_unique_id(
|
||||
device_info.mac_address, static_info
|
||||
)
|
||||
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If the name is "", we need to set it to None since otherwise
|
||||
|
@ -95,6 +95,22 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
}
|
||||
|
||||
|
||||
def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
|
||||
"""Build unique ID for entity, appending @device_id if it belongs to a sub-device.
|
||||
|
||||
This wrapper around build_unique_id ensures that entities belonging to sub-devices
|
||||
have their device_id appended to the unique_id to handle proper migration when
|
||||
entities move between devices.
|
||||
"""
|
||||
base_unique_id = build_unique_id(mac, entity_info)
|
||||
|
||||
# If entity belongs to a sub-device, append @device_id
|
||||
if entity_info.device_id:
|
||||
return f"{base_unique_id}@{entity_info.device_id}"
|
||||
|
||||
return base_unique_id
|
||||
|
||||
|
||||
class StoreData(TypedDict, total=False):
|
||||
"""ESPHome storage data."""
|
||||
|
||||
@ -160,6 +176,7 @@ class RuntimeEntryData:
|
||||
assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
device_id_to_name: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -222,7 +239,9 @@ class RuntimeEntryData:
|
||||
ent_reg = er.async_get(hass)
|
||||
for info in static_infos:
|
||||
if entry := ent_reg.async_get_entity_id(
|
||||
INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
|
||||
INFO_TYPE_TO_PLATFORM[type(info)],
|
||||
DOMAIN,
|
||||
build_device_unique_id(mac, info),
|
||||
):
|
||||
ent_reg.async_remove(entry)
|
||||
|
||||
@ -278,7 +297,8 @@ class RuntimeEntryData:
|
||||
if (
|
||||
(old_unique_id := info.unique_id)
|
||||
and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
|
||||
and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
|
||||
and (new_unique_id := build_device_unique_id(mac, info))
|
||||
!= old_unique_id
|
||||
and not registry_get_entity(platform, DOMAIN, new_unique_id)
|
||||
):
|
||||
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
|
||||
|
@ -527,6 +527,11 @@ class ESPHomeManager:
|
||||
device_info.name,
|
||||
device_mac,
|
||||
)
|
||||
# Build device_id_to_name mapping for efficient lookup
|
||||
entry_data.device_id_to_name = {
|
||||
sub_device.device_id: sub_device.name or device_info.name
|
||||
for sub_device in device_info.devices
|
||||
}
|
||||
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
|
||||
|
||||
entry_data.async_update_device_state()
|
||||
@ -751,6 +756,28 @@ def _async_setup_device_registry(
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
# Build sets of valid device identifiers and connections
|
||||
valid_connections = {
|
||||
(dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address))
|
||||
}
|
||||
valid_identifiers = {
|
||||
(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")
|
||||
for sub_device in device_info.devices
|
||||
}
|
||||
|
||||
# Remove devices that no longer exist
|
||||
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
|
||||
# Skip devices we want to keep
|
||||
if (
|
||||
device.connections & valid_connections
|
||||
or device.identifiers & valid_identifiers
|
||||
):
|
||||
continue
|
||||
# Remove everything else
|
||||
device_registry.async_remove_device(device.id)
|
||||
|
||||
sw_version = device_info.esphome_version
|
||||
if device_info.compilation_time:
|
||||
sw_version += f" ({device_info.compilation_time})"
|
||||
@ -779,11 +806,14 @@ def _async_setup_device_registry(
|
||||
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
|
||||
)
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
suggested_area: str | None = None
|
||||
if device_info.area and device_info.area.name:
|
||||
# Prefer device_info.area over suggested_area when area name is not empty
|
||||
suggested_area = device_info.area.name
|
||||
elif device_info.suggested_area:
|
||||
suggested_area = device_info.suggested_area
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
# Create/update main device
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
configuration_url=configuration_url,
|
||||
@ -794,6 +824,36 @@ def _async_setup_device_registry(
|
||||
sw_version=sw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
|
||||
# Handle sub devices
|
||||
# Find available areas from device_info
|
||||
areas_by_id = {area.area_id: area for area in device_info.areas}
|
||||
# Add the main device's area if it exists
|
||||
if device_info.area:
|
||||
areas_by_id[device_info.area.area_id] = device_info.area
|
||||
# Create/update sub devices that should exist
|
||||
for sub_device in device_info.devices:
|
||||
# Determine the area for this sub device
|
||||
sub_device_suggested_area: str | None = None
|
||||
if sub_device.area_id is not None and sub_device.area_id in areas_by_id:
|
||||
sub_device_suggested_area = areas_by_id[sub_device.area_id].name
|
||||
|
||||
sub_device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")},
|
||||
name=sub_device.name or device_entry.name,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
suggested_area=sub_device_suggested_area,
|
||||
)
|
||||
|
||||
# Update the sub device to set via_device_id
|
||||
device_registry.async_update_device(
|
||||
sub_device_entry.id,
|
||||
via_device_id=device_entry.id,
|
||||
)
|
||||
|
||||
return device_entry.id
|
||||
|
||||
|
||||
|
@ -12,6 +12,7 @@ from aioesphomeapi import (
|
||||
DeviceInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
SubDeviceInfo,
|
||||
build_unique_id,
|
||||
)
|
||||
import pytest
|
||||
@ -27,7 +28,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .conftest import MockESPHomeDevice, MockESPHomeDeviceType
|
||||
@ -699,3 +700,900 @@ async def test_deep_sleep_added_after_setup(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_entity_assignment_to_sub_device(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test entities are assigned to correct sub devices."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Define sub devices
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=0),
|
||||
SubDeviceInfo(device_id=22222222, name="Door Sensor", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
# Create entities that belong to different devices
|
||||
entity_info = [
|
||||
# Entity for main device (device_id=0)
|
||||
BinarySensorInfo(
|
||||
object_id="main_sensor",
|
||||
key=1,
|
||||
name="Main Sensor",
|
||||
unique_id="main_sensor",
|
||||
device_id=0,
|
||||
),
|
||||
# Entity for sub device 1
|
||||
BinarySensorInfo(
|
||||
object_id="motion",
|
||||
key=2,
|
||||
name="Motion",
|
||||
unique_id="motion",
|
||||
device_id=11111111,
|
||||
),
|
||||
# Entity for sub device 2
|
||||
BinarySensorInfo(
|
||||
object_id="door",
|
||||
key=3,
|
||||
name="Door",
|
||||
unique_id="door",
|
||||
device_id=22222222,
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
BinarySensorState(key=2, state=False, missing_state=False),
|
||||
BinarySensorState(key=3, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check main device
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert main_device is not None
|
||||
|
||||
# Check entities are assigned to correct devices
|
||||
main_sensor = entity_registry.async_get("binary_sensor.test_main_sensor")
|
||||
assert main_sensor is not None
|
||||
assert main_sensor.device_id == main_device.id
|
||||
|
||||
# Check sub device 1 entity
|
||||
sub_device_1 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
assert sub_device_1 is not None
|
||||
|
||||
motion_sensor = entity_registry.async_get("binary_sensor.motion_sensor_motion")
|
||||
assert motion_sensor is not None
|
||||
assert motion_sensor.device_id == sub_device_1.id
|
||||
|
||||
# Check sub device 2 entity
|
||||
sub_device_2 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device_2 is not None
|
||||
|
||||
door_sensor = entity_registry.async_get("binary_sensor.door_sensor_door")
|
||||
assert door_sensor is not None
|
||||
assert door_sensor.device_id == sub_device_2.id
|
||||
|
||||
# Check states
|
||||
assert hass.states.get("binary_sensor.test_main_sensor").state == STATE_ON
|
||||
assert hass.states.get("binary_sensor.motion_sensor_motion").state == STATE_OFF
|
||||
assert hass.states.get("binary_sensor.door_sensor_door").state == STATE_ON
|
||||
|
||||
# Check entity friendly names
|
||||
# Main device entity should have: "{device_name} {entity_name}"
|
||||
main_sensor_state = hass.states.get("binary_sensor.test_main_sensor")
|
||||
assert main_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Test Main Sensor"
|
||||
|
||||
# Sub device 1 entity should have: "Motion Sensor Motion"
|
||||
motion_sensor_state = hass.states.get("binary_sensor.motion_sensor_motion")
|
||||
assert motion_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Motion Sensor Motion"
|
||||
|
||||
# Sub device 2 entity should have: "Door Sensor Door"
|
||||
door_sensor_state = hass.states.get("binary_sensor.door_sensor_door")
|
||||
assert door_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Door Sensor Door"
|
||||
|
||||
|
||||
async def test_entity_friendly_names_with_empty_device_names(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test entity friendly names when sub-devices have empty names."""
|
||||
# Define sub devices with different name scenarios
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name
|
||||
SubDeviceInfo(
|
||||
device_id=22222222, name="Kitchen Light", area_id=0
|
||||
), # Valid name
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
"friendly_name": "Main Device",
|
||||
}
|
||||
|
||||
# Entity on sub-device with empty name
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="motion",
|
||||
key=1,
|
||||
name="Motion Detected",
|
||||
device_id=11111111,
|
||||
),
|
||||
# Entity on sub-device with valid name
|
||||
BinarySensorInfo(
|
||||
object_id="status",
|
||||
key=2,
|
||||
name="Status",
|
||||
device_id=22222222,
|
||||
),
|
||||
# Entity with empty name on sub-device with valid name
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=3,
|
||||
name="", # Empty entity name
|
||||
device_id=22222222,
|
||||
),
|
||||
# Entity on main device
|
||||
BinarySensorInfo(
|
||||
object_id="main_status",
|
||||
key=4,
|
||||
name="Main Status",
|
||||
device_id=0,
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
BinarySensorState(key=2, state=False, missing_state=False),
|
||||
BinarySensorState(key=3, state=True, missing_state=False),
|
||||
BinarySensorState(key=4, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check entity friendly name on sub-device with empty name
|
||||
# Since sub device has empty name, it falls back to main device name "test"
|
||||
state_1 = hass.states.get("binary_sensor.test_motion")
|
||||
assert state_1 is not None
|
||||
# With has_entity_name, friendly name is "{device_name} {entity_name}"
|
||||
# Since sub-device falls back to main device name: "Main Device Motion Detected"
|
||||
assert state_1.attributes[ATTR_FRIENDLY_NAME] == "Main Device Motion Detected"
|
||||
|
||||
# Check entity friendly name on sub-device with valid name
|
||||
state_2 = hass.states.get("binary_sensor.kitchen_light_status")
|
||||
assert state_2 is not None
|
||||
# Device has name "Kitchen Light", entity has name "Status"
|
||||
assert state_2.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light Status"
|
||||
|
||||
# Test entity with empty name on sub-device
|
||||
state_3 = hass.states.get("binary_sensor.kitchen_light")
|
||||
assert state_3 is not None
|
||||
# Entity has empty name, so friendly name is just the device name
|
||||
assert state_3.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light"
|
||||
|
||||
# Test entity on main device
|
||||
state_4 = hass.states.get("binary_sensor.test_main_status")
|
||||
assert state_4 is not None
|
||||
assert state_4.attributes[ATTR_FRIENDLY_NAME] == "Main Device Main Status"
|
||||
|
||||
|
||||
async def test_entity_switches_between_devices(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that entities can switch between devices correctly."""
|
||||
# Define sub devices
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0),
|
||||
SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
# Create initial entity assigned to main device (no device_id)
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Test Sensor",
|
||||
unique_id="sensor",
|
||||
# device_id omitted - entity belongs to main device
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Verify entity is on main device
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert main_device is not None
|
||||
|
||||
sensor_entity = entity_registry.async_get("binary_sensor.test_sensor")
|
||||
assert sensor_entity is not None
|
||||
assert sensor_entity.device_id == main_device.id
|
||||
|
||||
# Test 1: Main device → Sub device 1
|
||||
updated_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Test Sensor",
|
||||
unique_id="sensor",
|
||||
device_id=11111111, # Now on sub device 1
|
||||
),
|
||||
]
|
||||
|
||||
# Update the entity info by changing what the mock returns
|
||||
mock_client.list_entities_services = AsyncMock(
|
||||
return_value=(updated_entity_info, [])
|
||||
)
|
||||
# Trigger a reconnect to simulate the entity info update
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
|
||||
# Verify entity is now on sub device 1
|
||||
sub_device_1 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
assert sub_device_1 is not None
|
||||
|
||||
sensor_entity = entity_registry.async_get("binary_sensor.test_sensor")
|
||||
assert sensor_entity is not None
|
||||
assert sensor_entity.device_id == sub_device_1.id
|
||||
|
||||
# Test 2: Sub device 1 → Sub device 2
|
||||
updated_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Test Sensor",
|
||||
unique_id="sensor",
|
||||
device_id=22222222, # Now on sub device 2
|
||||
),
|
||||
]
|
||||
|
||||
mock_client.list_entities_services = AsyncMock(
|
||||
return_value=(updated_entity_info, [])
|
||||
)
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
|
||||
# Verify entity is now on sub device 2
|
||||
sub_device_2 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device_2 is not None
|
||||
|
||||
sensor_entity = entity_registry.async_get("binary_sensor.test_sensor")
|
||||
assert sensor_entity is not None
|
||||
assert sensor_entity.device_id == sub_device_2.id
|
||||
|
||||
# Test 3: Sub device 2 → Main device
|
||||
updated_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Test Sensor",
|
||||
unique_id="sensor",
|
||||
# device_id omitted - back to main device
|
||||
),
|
||||
]
|
||||
|
||||
mock_client.list_entities_services = AsyncMock(
|
||||
return_value=(updated_entity_info, [])
|
||||
)
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
|
||||
# Verify entity is back on main device
|
||||
sensor_entity = entity_registry.async_get("binary_sensor.test_sensor")
|
||||
assert sensor_entity is not None
|
||||
assert sensor_entity.device_id == main_device.id
|
||||
|
||||
|
||||
async def test_entity_id_uses_sub_device_name(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that entity_id uses sub device name when entity belongs to sub device."""
|
||||
# Define sub devices
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="motion_sensor", area_id=0),
|
||||
SubDeviceInfo(device_id=22222222, name="door_sensor", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
"name": "main_device",
|
||||
}
|
||||
|
||||
# Create entities that belong to different devices
|
||||
entity_info = [
|
||||
# Entity for main device (device_id=0)
|
||||
BinarySensorInfo(
|
||||
object_id="main_sensor",
|
||||
key=1,
|
||||
name="Main Sensor",
|
||||
unique_id="main_sensor",
|
||||
device_id=0,
|
||||
),
|
||||
# Entity for sub device 1
|
||||
BinarySensorInfo(
|
||||
object_id="motion",
|
||||
key=2,
|
||||
name="Motion",
|
||||
unique_id="motion",
|
||||
device_id=11111111,
|
||||
),
|
||||
# Entity for sub device 2
|
||||
BinarySensorInfo(
|
||||
object_id="door",
|
||||
key=3,
|
||||
name="Door",
|
||||
unique_id="door",
|
||||
device_id=22222222,
|
||||
),
|
||||
# Entity without name on sub device
|
||||
BinarySensorInfo(
|
||||
object_id="sensor_no_name",
|
||||
key=4,
|
||||
name="",
|
||||
unique_id="sensor_no_name",
|
||||
device_id=11111111,
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
BinarySensorState(key=2, state=False, missing_state=False),
|
||||
BinarySensorState(key=3, state=True, missing_state=False),
|
||||
BinarySensorState(key=4, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check entity_id for main device entity
|
||||
# Should be: binary_sensor.{main_device_name}_{object_id}
|
||||
assert hass.states.get("binary_sensor.main_device_main_sensor") is not None
|
||||
|
||||
# Check entity_id for sub device 1 entity
|
||||
# Should be: binary_sensor.{sub_device_name}_{object_id}
|
||||
assert hass.states.get("binary_sensor.motion_sensor_motion") is not None
|
||||
|
||||
# Check entity_id for sub device 2 entity
|
||||
# Should be: binary_sensor.{sub_device_name}_{object_id}
|
||||
assert hass.states.get("binary_sensor.door_sensor_door") is not None
|
||||
|
||||
# Check entity_id for entity without name on sub device
|
||||
# Should be: binary_sensor.{sub_device_name}
|
||||
assert hass.states.get("binary_sensor.motion_sensor") is not None
|
||||
|
||||
|
||||
async def test_entity_id_with_empty_sub_device_name(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test entity_id when sub device has empty name (falls back to main device name)."""
|
||||
# Define sub device with empty name
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
"name": "main_device",
|
||||
}
|
||||
|
||||
# Create entity on sub device with empty name
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Sensor",
|
||||
unique_id="sensor",
|
||||
device_id=11111111,
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# When sub device has empty name, entity_id should use main device name
|
||||
# Should be: binary_sensor.{main_device_name}_{object_id}
|
||||
assert hass.states.get("binary_sensor.main_device_sensor") is not None
|
||||
|
||||
|
||||
async def test_unique_id_migration_when_entity_moves_between_devices(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that unique_id is migrated when entity moves between devices while entity_id stays the same."""
|
||||
# Initial setup: entity on main device
|
||||
device_info = {
|
||||
"name": "test",
|
||||
"devices": [], # No sub-devices initially
|
||||
}
|
||||
|
||||
# Entity on main device
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature",
|
||||
key=1,
|
||||
name="Temperature",
|
||||
unique_id="unused", # This field is not used by the integration
|
||||
device_id=0, # Main device
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check initial entity
|
||||
state = hass.states.get("binary_sensor.test_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get the entity from registry
|
||||
entity_entry = entity_registry.async_get("binary_sensor.test_temperature")
|
||||
assert entity_entry is not None
|
||||
initial_unique_id = entity_entry.unique_id
|
||||
# Initial unique_id should not have @device_id suffix since it's on main device
|
||||
assert "@" not in initial_unique_id
|
||||
|
||||
# Add sub-device to device info
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
||||
]
|
||||
|
||||
# Get the config entry from hass
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
|
||||
# Build device_id_to_name mapping like manager.py does
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.device_id_to_name = {
|
||||
sub_device.device_id: sub_device.name for sub_device in sub_devices
|
||||
}
|
||||
|
||||
# Create a new DeviceInfo with sub-devices since it's frozen
|
||||
# Get the current device info and convert to dict
|
||||
current_device_info = mock_client.device_info.return_value
|
||||
device_info_dict = asdict(current_device_info)
|
||||
|
||||
# Update the devices list
|
||||
device_info_dict["devices"] = sub_devices
|
||||
|
||||
# Create new DeviceInfo with updated devices
|
||||
new_device_info = DeviceInfo(**device_info_dict)
|
||||
|
||||
# Update mock_client to return new device info
|
||||
mock_client.device_info.return_value = new_device_info
|
||||
|
||||
# Update entity info - same key and object_id but now on sub-device
|
||||
new_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature", # Same object_id
|
||||
key=1, # Same key - this is what identifies the entity
|
||||
name="Temperature",
|
||||
unique_id="unused", # This field is not used
|
||||
device_id=22222222, # Now on sub-device
|
||||
),
|
||||
]
|
||||
|
||||
# Update the entity info by changing what the mock returns
|
||||
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
||||
|
||||
# Trigger a reconnect to simulate the entity info update
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
|
||||
# Wait for entity to be updated
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The entity_id doesn't change when moving between devices
|
||||
# Only the unique_id gets updated with @device_id suffix
|
||||
state = hass.states.get("binary_sensor.test_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get updated entity from registry - entity_id should be the same
|
||||
entity_entry = entity_registry.async_get("binary_sensor.test_temperature")
|
||||
assert entity_entry is not None
|
||||
|
||||
# Unique ID should have been migrated to include @device_id
|
||||
# This is done by our build_device_unique_id wrapper
|
||||
expected_unique_id = f"{initial_unique_id}@22222222"
|
||||
assert entity_entry.unique_id == expected_unique_id
|
||||
|
||||
# Entity should now be associated with the sub-device
|
||||
sub_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device is not None
|
||||
assert entity_entry.device_id == sub_device.id
|
||||
|
||||
|
||||
async def test_unique_id_migration_sub_device_to_main_device(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that unique_id is migrated when entity moves from sub-device to main device."""
|
||||
# Initial setup: entity on sub-device
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"name": "test",
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
# Entity on sub-device
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature",
|
||||
key=1,
|
||||
name="Temperature",
|
||||
unique_id="unused",
|
||||
device_id=22222222, # On sub-device
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check initial entity
|
||||
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get the entity from registry
|
||||
entity_entry = entity_registry.async_get(
|
||||
"binary_sensor.kitchen_controller_temperature"
|
||||
)
|
||||
assert entity_entry is not None
|
||||
initial_unique_id = entity_entry.unique_id
|
||||
# Initial unique_id should have @device_id suffix since it's on sub-device
|
||||
assert "@22222222" in initial_unique_id
|
||||
|
||||
# Update entity info - move to main device
|
||||
new_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature",
|
||||
key=1,
|
||||
name="Temperature",
|
||||
unique_id="unused",
|
||||
device_id=0, # Now on main device
|
||||
),
|
||||
]
|
||||
|
||||
# Update the entity info
|
||||
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
||||
|
||||
# Trigger a reconnect
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The entity_id should remain the same
|
||||
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get updated entity from registry
|
||||
entity_entry = entity_registry.async_get(
|
||||
"binary_sensor.kitchen_controller_temperature"
|
||||
)
|
||||
assert entity_entry is not None
|
||||
|
||||
# Unique ID should have been migrated to remove @device_id suffix
|
||||
expected_unique_id = initial_unique_id.replace("@22222222", "")
|
||||
assert entity_entry.unique_id == expected_unique_id
|
||||
|
||||
# Entity should now be associated with the main device
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert main_device is not None
|
||||
assert entity_entry.device_id == main_device.id
|
||||
|
||||
|
||||
async def test_unique_id_migration_between_sub_devices(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that unique_id is migrated when entity moves between sub-devices."""
|
||||
# Initial setup: two sub-devices
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
||||
SubDeviceInfo(device_id=33333333, name="bedroom_controller", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"name": "test",
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
# Entity on first sub-device
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature",
|
||||
key=1,
|
||||
name="Temperature",
|
||||
unique_id="unused",
|
||||
device_id=22222222, # On kitchen_controller
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Check initial entity
|
||||
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get the entity from registry
|
||||
entity_entry = entity_registry.async_get(
|
||||
"binary_sensor.kitchen_controller_temperature"
|
||||
)
|
||||
assert entity_entry is not None
|
||||
initial_unique_id = entity_entry.unique_id
|
||||
# Initial unique_id should have @22222222 suffix
|
||||
assert "@22222222" in initial_unique_id
|
||||
|
||||
# Update entity info - move to second sub-device
|
||||
new_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="temperature",
|
||||
key=1,
|
||||
name="Temperature",
|
||||
unique_id="unused",
|
||||
device_id=33333333, # Now on bedroom_controller
|
||||
),
|
||||
]
|
||||
|
||||
# Update the entity info
|
||||
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
||||
|
||||
# Trigger a reconnect
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The entity_id should remain the same
|
||||
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
||||
assert state is not None
|
||||
|
||||
# Get updated entity from registry
|
||||
entity_entry = entity_registry.async_get(
|
||||
"binary_sensor.kitchen_controller_temperature"
|
||||
)
|
||||
assert entity_entry is not None
|
||||
|
||||
# Unique ID should have been migrated from @22222222 to @33333333
|
||||
expected_unique_id = initial_unique_id.replace("@22222222", "@33333333")
|
||||
assert entity_entry.unique_id == expected_unique_id
|
||||
|
||||
# Entity should now be associated with the second sub-device
|
||||
bedroom_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
|
||||
)
|
||||
assert bedroom_device is not None
|
||||
assert entity_entry.device_id == bedroom_device.id
|
||||
|
||||
|
||||
async def test_entity_device_id_rename_in_yaml(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that entities are re-added as new when user renames device_id in YAML config."""
|
||||
# Initial setup: entity on sub-device with device_id 11111111
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="old_device", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"name": "test",
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
# Entity on sub-device
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor",
|
||||
key=1,
|
||||
name="Sensor",
|
||||
unique_id="unused",
|
||||
device_id=11111111,
|
||||
),
|
||||
]
|
||||
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Verify initial entity setup
|
||||
state = hass.states.get("binary_sensor.old_device_sensor")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
# Wait for entity to be registered
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the entity from registry
|
||||
entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor")
|
||||
assert entity_entry is not None
|
||||
initial_unique_id = entity_entry.unique_id
|
||||
# Should have @11111111 suffix
|
||||
assert "@11111111" in initial_unique_id
|
||||
|
||||
# Simulate user renaming device_id in YAML config
|
||||
# The device_id hash changes from 11111111 to 99999999
|
||||
# This is treated as a completely new device
|
||||
renamed_sub_devices = [
|
||||
SubDeviceInfo(device_id=99999999, name="renamed_device", area_id=0),
|
||||
]
|
||||
|
||||
# Get the config entry from hass
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
|
||||
# Update device_id_to_name mapping
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.device_id_to_name = {
|
||||
sub_device.device_id: sub_device.name for sub_device in renamed_sub_devices
|
||||
}
|
||||
|
||||
# Create new DeviceInfo with renamed device
|
||||
current_device_info = mock_client.device_info.return_value
|
||||
device_info_dict = asdict(current_device_info)
|
||||
device_info_dict["devices"] = renamed_sub_devices
|
||||
new_device_info = DeviceInfo(**device_info_dict)
|
||||
mock_client.device_info.return_value = new_device_info
|
||||
|
||||
# Entity info now has the new device_id
|
||||
new_entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="sensor", # Same object_id
|
||||
key=1, # Same key
|
||||
name="Sensor",
|
||||
unique_id="unused",
|
||||
device_id=99999999, # New device_id after rename
|
||||
),
|
||||
]
|
||||
|
||||
# Update the entity info
|
||||
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
||||
|
||||
# Trigger a reconnect to simulate the YAML config change
|
||||
await device.mock_disconnect(expected_disconnect=False)
|
||||
await device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The old entity should be gone (device was deleted)
|
||||
state = hass.states.get("binary_sensor.old_device_sensor")
|
||||
assert state is None
|
||||
|
||||
# A new entity should exist with a new entity_id based on the new device name
|
||||
# This is a completely new entity, not a migrated one
|
||||
state = hass.states.get("binary_sensor.renamed_device_sensor")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
# Get the new entity from registry
|
||||
entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor")
|
||||
assert entity_entry is not None
|
||||
|
||||
# Unique ID should have the new device_id
|
||||
base_unique_id = initial_unique_id.replace("@11111111", "")
|
||||
expected_unique_id = f"{base_unique_id}@99999999"
|
||||
assert entity_entry.unique_id == expected_unique_id
|
||||
|
||||
# Entity should be associated with the new device
|
||||
renamed_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_99999999")}
|
||||
)
|
||||
assert renamed_device is not None
|
||||
assert entity_entry.device_id == renamed_device.id
|
||||
|
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock, call
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
AreaInfo,
|
||||
DeviceInfo,
|
||||
EncryptionPlaintextAPIError,
|
||||
HomeassistantServiceCall,
|
||||
@ -14,6 +15,7 @@ from aioesphomeapi import (
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
RequiresEncryptionAPIError,
|
||||
SubDeviceInfo,
|
||||
UserService,
|
||||
UserServiceArg,
|
||||
UserServiceArgType,
|
||||
@ -1179,6 +1181,29 @@ async def test_esphome_device_with_suggested_area(
|
||||
assert dev.suggested_area == "kitchen"
|
||||
|
||||
|
||||
async def test_esphome_device_area_priority(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test that device_info.area takes priority over suggested_area."""
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info={
|
||||
"suggested_area": "kitchen",
|
||||
"area": AreaInfo(area_id=0, name="Living Room"),
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
entry = device.entry
|
||||
dev = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
|
||||
)
|
||||
# Should use device_info.area.name instead of suggested_area
|
||||
assert dev.suggested_area == "Living Room"
|
||||
|
||||
|
||||
async def test_esphome_device_with_project(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
@ -1500,3 +1525,266 @@ async def test_assist_in_progress_issue_deleted(
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_sub_device_creation(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sub devices are created in device registry."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Define areas
|
||||
areas = [
|
||||
AreaInfo(area_id=1, name="Living Room"),
|
||||
AreaInfo(area_id=2, name="Bedroom"),
|
||||
AreaInfo(area_id=3, name="Kitchen"),
|
||||
]
|
||||
|
||||
# Define sub devices
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=1),
|
||||
SubDeviceInfo(device_id=22222222, name="Light Switch", area_id=1),
|
||||
SubDeviceInfo(device_id=33333333, name="Temperature Sensor", area_id=2),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"areas": areas,
|
||||
"devices": sub_devices,
|
||||
"area": AreaInfo(area_id=0, name="Main Hub"),
|
||||
}
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
# Check main device is created
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert main_device is not None
|
||||
assert main_device.suggested_area == "Main Hub"
|
||||
|
||||
# Check sub devices are created
|
||||
sub_device_1 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
assert sub_device_1 is not None
|
||||
assert sub_device_1.name == "Motion Sensor"
|
||||
assert sub_device_1.suggested_area == "Living Room"
|
||||
assert sub_device_1.via_device_id == main_device.id
|
||||
|
||||
sub_device_2 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device_2 is not None
|
||||
assert sub_device_2.name == "Light Switch"
|
||||
assert sub_device_2.suggested_area == "Living Room"
|
||||
assert sub_device_2.via_device_id == main_device.id
|
||||
|
||||
sub_device_3 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
|
||||
)
|
||||
assert sub_device_3 is not None
|
||||
assert sub_device_3.name == "Temperature Sensor"
|
||||
assert sub_device_3.suggested_area == "Bedroom"
|
||||
assert sub_device_3.via_device_id == main_device.id
|
||||
|
||||
|
||||
async def test_sub_device_cleanup(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sub devices are removed when they no longer exist."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Initial sub devices
|
||||
sub_devices_initial = [
|
||||
SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0),
|
||||
SubDeviceInfo(device_id=22222222, name="Device 2", area_id=0),
|
||||
SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices_initial,
|
||||
}
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
# Verify all sub devices exist
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
# Now update with fewer sub devices (device 2 removed)
|
||||
sub_devices_updated = [
|
||||
SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0),
|
||||
SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0),
|
||||
]
|
||||
|
||||
# Update device info
|
||||
device.device_info = DeviceInfo(
|
||||
name="test",
|
||||
friendly_name="Test",
|
||||
esphome_version="1.0.0",
|
||||
mac_address="11:22:33:44:55:AA",
|
||||
devices=sub_devices_updated,
|
||||
)
|
||||
|
||||
# Update the mock client to return the new device info
|
||||
mock_client.device_info = AsyncMock(return_value=device.device_info)
|
||||
|
||||
# Simulate reconnection which triggers device registry update
|
||||
await device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify device 2 was removed
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
is None
|
||||
) # Should be removed
|
||||
assert (
|
||||
device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
async def test_sub_device_with_empty_name(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sub devices with empty names are handled correctly."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Define sub devices with empty names
|
||||
sub_devices = [
|
||||
SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name
|
||||
SubDeviceInfo(device_id=22222222, name="Valid Name", area_id=0),
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"devices": sub_devices,
|
||||
}
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check sub device with empty name
|
||||
sub_device_1 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
assert sub_device_1 is not None
|
||||
# Empty sub-device names should fall back to main device name
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert sub_device_1.name == main_device.name
|
||||
|
||||
# Check sub device with valid name
|
||||
sub_device_2 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device_2 is not None
|
||||
assert sub_device_2.name == "Valid Name"
|
||||
|
||||
|
||||
async def test_sub_device_references_main_device_area(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sub devices can reference the main device's area."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Define areas - note we don't include area_id=0 in the areas list
|
||||
areas = [
|
||||
AreaInfo(area_id=1, name="Living Room"),
|
||||
AreaInfo(area_id=2, name="Bedroom"),
|
||||
]
|
||||
|
||||
# Define sub devices - one references the main device's area (area_id=0)
|
||||
sub_devices = [
|
||||
SubDeviceInfo(
|
||||
device_id=11111111, name="Motion Sensor", area_id=0
|
||||
), # Main device area
|
||||
SubDeviceInfo(
|
||||
device_id=22222222, name="Light Switch", area_id=1
|
||||
), # Living Room
|
||||
SubDeviceInfo(
|
||||
device_id=33333333, name="Temperature Sensor", area_id=2
|
||||
), # Bedroom
|
||||
]
|
||||
|
||||
device_info = {
|
||||
"areas": areas,
|
||||
"devices": sub_devices,
|
||||
"area": AreaInfo(area_id=0, name="Main Hub Area"),
|
||||
}
|
||||
|
||||
device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
# Check main device has correct area
|
||||
main_device = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)}
|
||||
)
|
||||
assert main_device is not None
|
||||
assert main_device.suggested_area == "Main Hub Area"
|
||||
|
||||
# Check sub device 1 uses main device's area
|
||||
sub_device_1 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")}
|
||||
)
|
||||
assert sub_device_1 is not None
|
||||
assert sub_device_1.suggested_area == "Main Hub Area"
|
||||
|
||||
# Check sub device 2 uses Living Room
|
||||
sub_device_2 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")}
|
||||
)
|
||||
assert sub_device_2 is not None
|
||||
assert sub_device_2.suggested_area == "Living Room"
|
||||
|
||||
# Check sub device 3 uses Bedroom
|
||||
sub_device_3 = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")}
|
||||
)
|
||||
assert sub_device_3 is not None
|
||||
assert sub_device_3.suggested_area == "Bedroom"
|
||||
|
Loading…
x
Reference in New Issue
Block a user