Skip to content

Commit 0f257e9

Browse files
committed
fixed breaking changes from HA
1 parent 1c74a71 commit 0f257e9

File tree

3 files changed

+313
-149
lines changed

3 files changed

+313
-149
lines changed

custom_components/smartvanio/entity.py

Lines changed: 214 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
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

1011
from aioesphomeapi import (
1112
APIConnectionError,
13+
DeviceInfo as EsphomeDeviceInfo,
1214
EntityCategory as EsphomeEntityCategory,
1315
EntityInfo,
1416
EntityState,
15-
build_unique_id,
1617
)
1718
import voluptuous as vol
1819

@@ -23,15 +24,25 @@
2324
config_validation as cv,
2425
device_registry as dr,
2526
entity_platform,
27+
entity_registry as er,
2628
)
2729
from homeassistant.helpers.device_registry import DeviceInfo
2830
from homeassistant.helpers.entity import Entity
2931
from homeassistant.helpers.entity_platform import AddEntitiesCallback
3032

33+
from .const import DOMAIN
34+
3135
# Import config flow so that it's added to the registry
32-
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
36+
from .entry_data import (
37+
DeviceEntityKey,
38+
ESPHomeConfigEntry,
39+
RuntimeEntryData,
40+
build_device_unique_id,
41+
)
3342
from .enum_mapper import EsphomeEnumMapper
3443

44+
_LOGGER = logging.getLogger(__name__)
45+
3546
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
3647
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
3748
_StateT = TypeVar("_StateT", bound=EntityState)
@@ -50,21 +61,111 @@ def async_static_info_updated(
5061
) -> None:
5162
"""Update entities of this platform when entities are listed."""
5263
current_infos = entry_data.info[info_type]
53-
new_infos: dict[int, EntityInfo] = {}
64+
device_info = entry_data.device_info
65+
if TYPE_CHECKING:
66+
assert device_info is not None
67+
new_infos: dict[DeviceEntityKey, EntityInfo] = {}
5468
add_entities: list[_EntityT] = []
5569

70+
ent_reg = er.async_get(hass)
71+
dev_reg = dr.async_get(hass)
72+
73+
# Track info by (info.device_id, info.key) to properly handle entities
74+
# moving between devices and support sub-devices with overlapping keys
5675
for info in infos:
57-
if not current_infos.pop(info.key, None):
58-
# Create new entity
76+
info_key = (info.device_id, info.key)
77+
new_infos[info_key] = info
78+
79+
# Try to find existing entity - first with current device_id
80+
old_info = current_infos.pop(info_key, None)
81+
82+
# If not found, search for entity with same key but different device_id
83+
# This handles the case where entity moved between devices
84+
if not old_info:
85+
for existing_device_id, existing_key in list(current_infos):
86+
if existing_key == info.key:
87+
# Found entity with same key but different device_id
88+
old_info = current_infos.pop((existing_device_id, existing_key))
89+
break
90+
91+
# Create new entity if it doesn't exist
92+
if not old_info:
5993
entity = entity_type(entry_data, platform.domain, info, state_type)
6094
add_entities.append(entity)
61-
new_infos[info.key] = info
95+
continue
96+
97+
# Entity exists - check if device_id has changed
98+
if old_info.device_id == info.device_id:
99+
continue
100+
101+
# Entity has switched devices, need to migrate unique_id and handle state subscriptions
102+
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
103+
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
104+
105+
# If entity not found in registry, re-add it
106+
# This happens when the device_id changed and the old device was deleted
107+
if entity_id is None:
108+
_LOGGER.info(
109+
"Entity with old unique_id %s not found in registry after device_id "
110+
"changed from %s to %s, re-adding entity",
111+
old_unique_id,
112+
old_info.device_id,
113+
info.device_id,
114+
)
115+
entity = entity_type(entry_data, platform.domain, info, state_type)
116+
add_entities.append(entity)
117+
continue
118+
119+
updates: dict[str, Any] = {}
120+
new_unique_id = build_device_unique_id(device_info.mac_address, info)
121+
122+
# Update unique_id if it changed
123+
if old_unique_id != new_unique_id:
124+
updates["new_unique_id"] = new_unique_id
125+
126+
# Update device assignment in registry
127+
if info.device_id:
128+
# Entity now belongs to a sub device
129+
new_device = dev_reg.async_get_device(
130+
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
131+
)
132+
else:
133+
# Entity now belongs to the main device
134+
new_device = dev_reg.async_get_device(
135+
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
136+
)
137+
138+
if new_device:
139+
updates["device_id"] = new_device.id
140+
141+
# Apply all registry updates at once
142+
if updates:
143+
ent_reg.async_update_entity(entity_id, **updates)
144+
145+
# IMPORTANT: The entity's device assignment in Home Assistant is only read when the entity
146+
# is first added. Updating the registry alone won't move the entity to the new device
147+
# in the UI. Additionally, the entity's state subscription is tied to the old device_id,
148+
# so it won't receive state updates for the new device_id.
149+
#
150+
# We must remove the old entity and re-add it to ensure:
151+
# 1. The entity appears under the correct device in the UI
152+
# 2. The entity's state subscription is updated to use the new device_id
153+
_LOGGER.debug(
154+
"Entity %s moving from device_id %s to %s",
155+
info.key,
156+
old_info.device_id,
157+
info.device_id,
158+
)
159+
160+
# Signal the existing entity to remove itself
161+
# The entity is registered with the old device_id, so we signal with that
162+
entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key)
163+
164+
# Create new entity with the new device_id
165+
add_entities.append(entity_type(entry_data, platform.domain, info, state_type))
62166

63167
# Anything still in current_infos is now gone
64168
if current_infos:
65-
device_info = entry_data.device_info
66-
if TYPE_CHECKING:
67-
assert device_info is not None
68169
entry_data.async_remove_entities(
69170
hass, current_infos.values(), device_info.mac_address
70171
)
@@ -131,6 +232,22 @@ def _wrapper(self: _EntityT) -> _R | None:
131232
return _wrapper
132233

133234

235+
def async_esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]](
236+
func: Callable[[_EntityT], Awaitable[_R | None]],
237+
) -> Callable[[_EntityT], Coroutine[Any, Any, _R | None]]:
238+
"""Wrap a state property of an esphome entity.
239+
240+
This checks if the state object in the entity is set
241+
and returns None if it is not set.
242+
"""
243+
244+
@functools.wraps(func)
245+
async def _wrapper(self: _EntityT) -> _R | None:
246+
return await func(self) if self._has_state else None
247+
248+
return _wrapper
249+
250+
134251
def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]](
135252
func: Callable[[_EntityT], float | None],
136253
) -> Callable[[_EntityT], float | None]:
@@ -153,7 +270,7 @@ def _wrapper(self: _EntityT) -> float | None:
153270
return _wrapper
154271

155272

156-
def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]](
273+
def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeBaseEntity](
157274
func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
158275
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
159276
"""Decorate ESPHome command calls that send commands/make changes to the device.
@@ -167,7 +284,12 @@ async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
167284
return await func(self, *args, **kwargs)
168285
except APIConnectionError as error:
169286
raise HomeAssistantError(
170-
f"Error communicating with device: {error}"
287+
translation_domain=DOMAIN,
288+
translation_key="error_communicating_with_device",
289+
translation_placeholders={
290+
"device_name": self._device_info.name,
291+
"error": str(error),
292+
},
171293
) from error
172294

173295
return handler
@@ -187,13 +309,22 @@ async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
187309
)
188310

189311

190-
class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
312+
class EsphomeBaseEntity(Entity):
191313
"""Define a base esphome entity."""
192314

315+
_attr_has_entity_name = True
193316
_attr_should_poll = False
317+
_device_info: EsphomeDeviceInfo
318+
device_entry: dr.DeviceEntry
319+
320+
321+
class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
322+
"""Define an esphome entity."""
323+
194324
_static_info: _InfoT
195325
_state: _StateT
196-
_has_state: bool
326+
_has_state: bool = False
327+
unique_id: str
197328

198329
def __init__(
199330
self,
@@ -207,34 +338,40 @@ def __init__(
207338
self._states = cast(dict[int, _StateT], entry_data.state[state_type])
208339
assert entry_data.device_info is not None
209340
device_info = entry_data.device_info
210-
self._device_info = device_info
211341
self._on_entry_data_changed()
212342
self._key = entity_info.key
213343
self._state_type = state_type
214344
self._on_static_info_update(entity_info)
215-
self._attr_device_info = DeviceInfo(
216-
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
217-
)
218-
#
219-
# If `friendly_name` is set, we use the Friendly naming rules, if
220-
# `friendly_name` is not set we make an exception to the naming rules for
221-
# backwards compatibility and use the Legacy naming rules.
222-
#
223-
# Friendly naming
224-
# - Friendly name is prepended to entity names
225-
# - Device Name is prepended to entity ids
226-
# - Entity id is constructed from device name and object id
227-
#
228-
# Legacy naming
229-
# - Device name is not prepended to entity names
230-
# - Device name is not prepended to entity ids
231-
# - Entity id is constructed from entity name
232-
#
233345

234-
if not device_info.friendly_name:
235-
return
236-
self._attr_has_entity_name = True
237-
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
346+
device_name = device_info.name
347+
# Determine the device connection based on whether this entity belongs to a sub device
348+
if entity_info.device_id:
349+
# Entity belongs to a sub device
350+
self._attr_device_info = DeviceInfo(
351+
identifiers={
352+
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
353+
}
354+
)
355+
# Use the pre-computed device_id_to_name mapping for O(1) lookup
356+
device_name = entry_data.device_id_to_name.get(
357+
entity_info.device_id, device_info.name
358+
)
359+
else:
360+
# Entity belongs to the main device
361+
self._attr_device_info = DeviceInfo(
362+
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
363+
)
364+
365+
if entity_info.name:
366+
self.entity_id = f"{domain}.{device_name}_{entity_info.name}"
367+
else:
368+
# https://github.com/home-assistant/core/issues/132532
369+
# If name is not set, ESPHome will use the sanitized friendly name
370+
# as the name, however we want to use the original object_id
371+
# as the entity_id before it is sanitized since the sanitizer
372+
# is not utf-8 aware. In this case, its always going to be
373+
# an empty string so we drop the object_id.
374+
self.entity_id = f"{domain}.{device_name}"
238375

239376
async def async_added_to_hass(self) -> None:
240377
"""Register callbacks."""
@@ -246,16 +383,40 @@ async def async_added_to_hass(self) -> None:
246383
)
247384
self.async_on_remove(
248385
entry_data.async_subscribe_state_update(
249-
self._state_type, self._key, self._on_state_update
386+
self._static_info.device_id,
387+
self._state_type,
388+
self._key,
389+
self._on_state_update,
250390
)
251391
)
252392
self.async_on_remove(
253393
entry_data.async_register_key_static_info_updated_callback(
254394
self._static_info, self._on_static_info_update
255395
)
256396
)
397+
# Register to be notified when this entity should remove itself
398+
# This happens when the entity moves to a different device
399+
self.async_on_remove(
400+
entry_data.async_register_entity_removal_callback(
401+
type(self._static_info),
402+
self._static_info.device_id,
403+
self._key,
404+
self._on_removal_signal,
405+
)
406+
)
257407
self._update_state_from_entry_data()
258408

409+
@callback
410+
def _on_removal_signal(self) -> None:
411+
"""Handle signal to remove this entity."""
412+
_LOGGER.debug(
413+
"Entity %s received removal signal due to device_id change",
414+
self.entity_id,
415+
)
416+
# Schedule the entity to be removed
417+
# This must be done as a task since we're in a callback
418+
self.hass.async_create_task(self.async_remove())
419+
259420
@callback
260421
def _on_static_info_update(self, static_info: EntityInfo) -> None:
261422
"""Save the static info for this entity when it changes.
@@ -268,9 +429,16 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None:
268429
static_info = cast(_InfoT, static_info)
269430
assert device_info
270431
self._static_info = static_info
271-
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
432+
self._attr_unique_id = build_device_unique_id(
433+
device_info.mac_address, static_info
434+
)
272435
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
273-
self._attr_name = static_info.name
436+
# https://github.com/home-assistant/core/issues/132532
437+
# If the name is "", we need to set it to None since otherwise
438+
# the friendly_name will be "{friendly_name} " with a trailing
439+
# space. ESPHome uses protobuf under the hood, and an empty field
440+
# gets a default value of "".
441+
self._attr_name = static_info.name if static_info.name else None
274442
if entity_category := static_info.entity_category:
275443
self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category)
276444
else:
@@ -300,6 +468,11 @@ def _on_state_update(self) -> None:
300468
@callback
301469
def _on_entry_data_changed(self) -> None:
302470
entry_data = self._entry_data
471+
# Update the device info since it can change
472+
# when the device is reconnected
473+
if TYPE_CHECKING:
474+
assert entry_data.device_info is not None
475+
self._device_info = entry_data.device_info
303476
self._api_version = entry_data.api_version
304477
self._client = entry_data.client
305478
if self._device_info.has_deep_sleep:
@@ -321,15 +494,12 @@ def _on_device_update(self) -> None:
321494
self.async_write_ha_state()
322495

323496

324-
class EsphomeAssistEntity(Entity):
497+
class EsphomeAssistEntity(EsphomeBaseEntity):
325498
"""Define a base entity for Assist Pipeline entities."""
326499

327-
_attr_has_entity_name = True
328-
_attr_should_poll = False
329-
330500
def __init__(self, entry_data: RuntimeEntryData) -> None:
331501
"""Initialize the binary sensor."""
332-
self._entry_data: RuntimeEntryData = entry_data
502+
self._entry_data = entry_data
333503
assert entry_data.device_info is not None
334504
device_info = entry_data.device_info
335505
self._device_info = device_info

0 commit comments

Comments
 (0)