Skip to content

Commit 0a884c7

Browse files
authored
Add subdevices support to ESPHome (home-assistant#147343)
1 parent 58e60fd commit 0a884c7

File tree

5 files changed

+1361
-20
lines changed

5 files changed

+1361
-20
lines changed

homeassistant/components/esphome/entity.py

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Awaitable, Callable, Coroutine
66
import functools
7+
import logging
78
import math
89
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast
910

@@ -13,7 +14,6 @@
1314
EntityCategory as EsphomeEntityCategory,
1415
EntityInfo,
1516
EntityState,
16-
build_unique_id,
1717
)
1818
import voluptuous as vol
1919

@@ -24,6 +24,7 @@
2424
config_validation as cv,
2525
device_registry as dr,
2626
entity_platform,
27+
entity_registry as er,
2728
)
2829
from homeassistant.helpers.device_registry import DeviceInfo
2930
from homeassistant.helpers.entity import Entity
@@ -32,9 +33,11 @@
3233
from .const import DOMAIN
3334

3435
# Import config flow so that it's added to the registry
35-
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
36+
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
3637
from .enum_mapper import EsphomeEnumMapper
3738

39+
_LOGGER = logging.getLogger(__name__)
40+
3841
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
3942
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
4043
_StateT = TypeVar("_StateT", bound=EntityState)
@@ -53,21 +56,74 @@ def async_static_info_updated(
5356
) -> None:
5457
"""Update entities of this platform when entities are listed."""
5558
current_infos = entry_data.info[info_type]
59+
device_info = entry_data.device_info
60+
if TYPE_CHECKING:
61+
assert device_info is not None
5662
new_infos: dict[int, EntityInfo] = {}
5763
add_entities: list[_EntityT] = []
5864

65+
ent_reg = er.async_get(hass)
66+
dev_reg = dr.async_get(hass)
67+
5968
for info in infos:
60-
if not current_infos.pop(info.key, None):
61-
# Create new entity
69+
new_infos[info.key] = info
70+
71+
# Create new entity if it doesn't exist
72+
if not (old_info := current_infos.pop(info.key, None)):
6273
entity = entity_type(entry_data, platform.domain, info, state_type)
6374
add_entities.append(entity)
64-
new_infos[info.key] = info
75+
continue
76+
77+
# Entity exists - check if device_id has changed
78+
if old_info.device_id == info.device_id:
79+
continue
80+
81+
# Entity has switched devices, need to migrate unique_id
82+
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
83+
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
84+
85+
# If entity not found in registry, re-add it
86+
# This happens when the device_id changed and the old device was deleted
87+
if entity_id is None:
88+
_LOGGER.info(
89+
"Entity with old unique_id %s not found in registry after device_id "
90+
"changed from %s to %s, re-adding entity",
91+
old_unique_id,
92+
old_info.device_id,
93+
info.device_id,
94+
)
95+
entity = entity_type(entry_data, platform.domain, info, state_type)
96+
add_entities.append(entity)
97+
continue
98+
99+
updates: dict[str, Any] = {}
100+
new_unique_id = build_device_unique_id(device_info.mac_address, info)
101+
102+
# Update unique_id if it changed
103+
if old_unique_id != new_unique_id:
104+
updates["new_unique_id"] = new_unique_id
105+
106+
# Update device assignment
107+
if info.device_id:
108+
# Entity now belongs to a sub device
109+
new_device = dev_reg.async_get_device(
110+
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
111+
)
112+
else:
113+
# Entity now belongs to the main device
114+
new_device = dev_reg.async_get_device(
115+
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
116+
)
117+
118+
if new_device:
119+
updates["device_id"] = new_device.id
120+
121+
# Apply all updates at once
122+
if updates:
123+
ent_reg.async_update_entity(entity_id, **updates)
65124

66125
# Anything still in current_infos is now gone
67126
if current_infos:
68-
device_info = entry_data.device_info
69-
if TYPE_CHECKING:
70-
assert device_info is not None
71127
entry_data.async_remove_entities(
72128
hass, current_infos.values(), device_info.mac_address
73129
)
@@ -244,19 +300,36 @@ def __init__(
244300
self._key = entity_info.key
245301
self._state_type = state_type
246302
self._on_static_info_update(entity_info)
247-
self._attr_device_info = DeviceInfo(
248-
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
249-
)
303+
304+
device_name = device_info.name
305+
# Determine the device connection based on whether this entity belongs to a sub device
306+
if entity_info.device_id:
307+
# Entity belongs to a sub device
308+
self._attr_device_info = DeviceInfo(
309+
identifiers={
310+
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
311+
}
312+
)
313+
# Use the pre-computed device_id_to_name mapping for O(1) lookup
314+
device_name = entry_data.device_id_to_name.get(
315+
entity_info.device_id, device_info.name
316+
)
317+
else:
318+
# Entity belongs to the main device
319+
self._attr_device_info = DeviceInfo(
320+
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
321+
)
322+
250323
if entity_info.name:
251-
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
324+
self.entity_id = f"{domain}.{device_name}_{entity_info.object_id}"
252325
else:
253326
# https://github.com/home-assistant/core/issues/132532
254327
# If name is not set, ESPHome will use the sanitized friendly name
255328
# as the name, however we want to use the original object_id
256329
# as the entity_id before it is sanitized since the sanitizer
257330
# is not utf-8 aware. In this case, its always going to be
258331
# an empty string so we drop the object_id.
259-
self.entity_id = f"{domain}.{device_info.name}"
332+
self.entity_id = f"{domain}.{device_name}"
260333

261334
async def async_added_to_hass(self) -> None:
262335
"""Register callbacks."""
@@ -290,7 +363,9 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None:
290363
static_info = cast(_InfoT, static_info)
291364
assert device_info
292365
self._static_info = static_info
293-
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
366+
self._attr_unique_id = build_device_unique_id(
367+
device_info.mac_address, static_info
368+
)
294369
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
295370
# https://github.com/home-assistant/core/issues/132532
296371
# If the name is "", we need to set it to None since otherwise

homeassistant/components/esphome/entry_data.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@
9595
}
9696

9797

98+
def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
99+
"""Build unique ID for entity, appending @device_id if it belongs to a sub-device.
100+
101+
This wrapper around build_unique_id ensures that entities belonging to sub-devices
102+
have their device_id appended to the unique_id to handle proper migration when
103+
entities move between devices.
104+
"""
105+
base_unique_id = build_unique_id(mac, entity_info)
106+
107+
# If entity belongs to a sub-device, append @device_id
108+
if entity_info.device_id:
109+
return f"{base_unique_id}@{entity_info.device_id}"
110+
111+
return base_unique_id
112+
113+
98114
class StoreData(TypedDict, total=False):
99115
"""ESPHome storage data."""
100116

@@ -160,6 +176,7 @@ class RuntimeEntryData:
160176
assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
161177
default_factory=list
162178
)
179+
device_id_to_name: dict[int, str] = field(default_factory=dict)
163180

164181
@property
165182
def name(self) -> str:
@@ -222,7 +239,9 @@ def async_remove_entities(
222239
ent_reg = er.async_get(hass)
223240
for info in static_infos:
224241
if entry := ent_reg.async_get_entity_id(
225-
INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
242+
INFO_TYPE_TO_PLATFORM[type(info)],
243+
DOMAIN,
244+
build_device_unique_id(mac, info),
226245
):
227246
ent_reg.async_remove(entry)
228247

@@ -278,7 +297,8 @@ async def async_update_static_infos(
278297
if (
279298
(old_unique_id := info.unique_id)
280299
and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
281-
and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
300+
and (new_unique_id := build_device_unique_id(mac, info))
301+
!= old_unique_id
282302
and not registry_get_entity(platform, DOMAIN, new_unique_id)
283303
):
284304
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)

homeassistant/components/esphome/manager.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,11 @@ async def _on_connect(self) -> None:
527527
device_info.name,
528528
device_mac,
529529
)
530+
# Build device_id_to_name mapping for efficient lookup
531+
entry_data.device_id_to_name = {
532+
sub_device.device_id: sub_device.name or device_info.name
533+
for sub_device in device_info.devices
534+
}
530535
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
531536

532537
entry_data.async_update_device_state()
@@ -751,6 +756,28 @@ def _async_setup_device_registry(
751756
device_info = entry_data.device_info
752757
if TYPE_CHECKING:
753758
assert device_info is not None
759+
760+
device_registry = dr.async_get(hass)
761+
# Build sets of valid device identifiers and connections
762+
valid_connections = {
763+
(dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address))
764+
}
765+
valid_identifiers = {
766+
(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")
767+
for sub_device in device_info.devices
768+
}
769+
770+
# Remove devices that no longer exist
771+
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
772+
# Skip devices we want to keep
773+
if (
774+
device.connections & valid_connections
775+
or device.identifiers & valid_identifiers
776+
):
777+
continue
778+
# Remove everything else
779+
device_registry.async_remove_device(device.id)
780+
754781
sw_version = device_info.esphome_version
755782
if device_info.compilation_time:
756783
sw_version += f" ({device_info.compilation_time})"
@@ -779,11 +806,14 @@ def _async_setup_device_registry(
779806
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
780807
)
781808

782-
suggested_area = None
783-
if device_info.suggested_area:
809+
suggested_area: str | None = None
810+
if device_info.area and device_info.area.name:
811+
# Prefer device_info.area over suggested_area when area name is not empty
812+
suggested_area = device_info.area.name
813+
elif device_info.suggested_area:
784814
suggested_area = device_info.suggested_area
785815

786-
device_registry = dr.async_get(hass)
816+
# Create/update main device
787817
device_entry = device_registry.async_get_or_create(
788818
config_entry_id=entry.entry_id,
789819
configuration_url=configuration_url,
@@ -794,6 +824,36 @@ def _async_setup_device_registry(
794824
sw_version=sw_version,
795825
suggested_area=suggested_area,
796826
)
827+
828+
# Handle sub devices
829+
# Find available areas from device_info
830+
areas_by_id = {area.area_id: area for area in device_info.areas}
831+
# Add the main device's area if it exists
832+
if device_info.area:
833+
areas_by_id[device_info.area.area_id] = device_info.area
834+
# Create/update sub devices that should exist
835+
for sub_device in device_info.devices:
836+
# Determine the area for this sub device
837+
sub_device_suggested_area: str | None = None
838+
if sub_device.area_id is not None and sub_device.area_id in areas_by_id:
839+
sub_device_suggested_area = areas_by_id[sub_device.area_id].name
840+
841+
sub_device_entry = device_registry.async_get_or_create(
842+
config_entry_id=entry.entry_id,
843+
identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")},
844+
name=sub_device.name or device_entry.name,
845+
manufacturer=manufacturer,
846+
model=model,
847+
sw_version=sw_version,
848+
suggested_area=sub_device_suggested_area,
849+
)
850+
851+
# Update the sub device to set via_device_id
852+
device_registry.async_update_device(
853+
sub_device_entry.id,
854+
via_device_id=device_entry.id,
855+
)
856+
797857
return device_entry.id
798858

799859

0 commit comments

Comments
 (0)