Add subdevices support to ESPHome (#147343)

This commit is contained in:
J. Nick Koston 2025-06-25 11:24:30 +02:00 committed by GitHub
parent 58e60fdfac
commit 0a884c7253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1361 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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"