4
4
5
5
from collections .abc import Awaitable , Callable , Coroutine
6
6
import functools
7
+ import logging
7
8
import math
8
9
from typing import TYPE_CHECKING , Any , Concatenate , Generic , TypeVar , cast
9
10
10
11
from aioesphomeapi import (
11
12
APIConnectionError ,
13
+ DeviceInfo as EsphomeDeviceInfo ,
12
14
EntityCategory as EsphomeEntityCategory ,
13
15
EntityInfo ,
14
16
EntityState ,
15
- build_unique_id ,
16
17
)
17
18
import voluptuous as vol
18
19
23
24
config_validation as cv ,
24
25
device_registry as dr ,
25
26
entity_platform ,
27
+ entity_registry as er ,
26
28
)
27
29
from homeassistant .helpers .device_registry import DeviceInfo
28
30
from homeassistant .helpers .entity import Entity
29
31
from homeassistant .helpers .entity_platform import AddEntitiesCallback
30
32
33
+ from .const import DOMAIN
34
+
31
35
# 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
+ )
33
42
from .enum_mapper import EsphomeEnumMapper
34
43
44
+ _LOGGER = logging .getLogger (__name__ )
45
+
35
46
_InfoT = TypeVar ("_InfoT" , bound = EntityInfo )
36
47
_EntityT = TypeVar ("_EntityT" , bound = "EsphomeEntity[Any,Any]" )
37
48
_StateT = TypeVar ("_StateT" , bound = EntityState )
@@ -50,21 +61,111 @@ def async_static_info_updated(
50
61
) -> None :
51
62
"""Update entities of this platform when entities are listed."""
52
63
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 ] = {}
54
68
add_entities : list [_EntityT ] = []
55
69
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
56
75
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 :
59
93
entity = entity_type (entry_data , platform .domain , info , state_type )
60
94
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 ))
62
166
63
167
# Anything still in current_infos is now gone
64
168
if current_infos :
65
- device_info = entry_data .device_info
66
- if TYPE_CHECKING :
67
- assert device_info is not None
68
169
entry_data .async_remove_entities (
69
170
hass , current_infos .values (), device_info .mac_address
70
171
)
@@ -131,6 +232,22 @@ def _wrapper(self: _EntityT) -> _R | None:
131
232
return _wrapper
132
233
133
234
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
+
134
251
def esphome_float_state_property [_EntityT : EsphomeEntity [Any , Any ]](
135
252
func : Callable [[_EntityT ], float | None ],
136
253
) -> Callable [[_EntityT ], float | None ]:
@@ -153,7 +270,7 @@ def _wrapper(self: _EntityT) -> float | None:
153
270
return _wrapper
154
271
155
272
156
- def convert_api_error_ha_error [** _P , _R , _EntityT : EsphomeEntity [ Any , Any ] ](
273
+ def convert_api_error_ha_error [** _P , _R , _EntityT : EsphomeBaseEntity ](
157
274
func : Callable [Concatenate [_EntityT , _P ], Awaitable [None ]],
158
275
) -> Callable [Concatenate [_EntityT , _P ], Coroutine [Any , Any , None ]]:
159
276
"""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:
167
284
return await func (self , * args , ** kwargs )
168
285
except APIConnectionError as error :
169
286
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
+ },
171
293
) from error
172
294
173
295
return handler
@@ -187,13 +309,22 @@ async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
187
309
)
188
310
189
311
190
- class EsphomeEntity (Entity , Generic [ _InfoT , _StateT ] ):
312
+ class EsphomeBaseEntity (Entity ):
191
313
"""Define a base esphome entity."""
192
314
315
+ _attr_has_entity_name = True
193
316
_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
+
194
324
_static_info : _InfoT
195
325
_state : _StateT
196
- _has_state : bool
326
+ _has_state : bool = False
327
+ unique_id : str
197
328
198
329
def __init__ (
199
330
self ,
@@ -207,34 +338,40 @@ def __init__(
207
338
self ._states = cast (dict [int , _StateT ], entry_data .state [state_type ])
208
339
assert entry_data .device_info is not None
209
340
device_info = entry_data .device_info
210
- self ._device_info = device_info
211
341
self ._on_entry_data_changed ()
212
342
self ._key = entity_info .key
213
343
self ._state_type = state_type
214
344
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
- #
233
345
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 } "
238
375
239
376
async def async_added_to_hass (self ) -> None :
240
377
"""Register callbacks."""
@@ -246,16 +383,40 @@ async def async_added_to_hass(self) -> None:
246
383
)
247
384
self .async_on_remove (
248
385
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 ,
250
390
)
251
391
)
252
392
self .async_on_remove (
253
393
entry_data .async_register_key_static_info_updated_callback (
254
394
self ._static_info , self ._on_static_info_update
255
395
)
256
396
)
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
+ )
257
407
self ._update_state_from_entry_data ()
258
408
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
+
259
420
@callback
260
421
def _on_static_info_update (self , static_info : EntityInfo ) -> None :
261
422
"""Save the static info for this entity when it changes.
@@ -268,9 +429,16 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None:
268
429
static_info = cast (_InfoT , static_info )
269
430
assert device_info
270
431
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
+ )
272
435
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
274
442
if entity_category := static_info .entity_category :
275
443
self ._attr_entity_category = ENTITY_CATEGORIES .from_esphome (entity_category )
276
444
else :
@@ -300,6 +468,11 @@ def _on_state_update(self) -> None:
300
468
@callback
301
469
def _on_entry_data_changed (self ) -> None :
302
470
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
303
476
self ._api_version = entry_data .api_version
304
477
self ._client = entry_data .client
305
478
if self ._device_info .has_deep_sleep :
@@ -321,15 +494,12 @@ def _on_device_update(self) -> None:
321
494
self .async_write_ha_state ()
322
495
323
496
324
- class EsphomeAssistEntity (Entity ):
497
+ class EsphomeAssistEntity (EsphomeBaseEntity ):
325
498
"""Define a base entity for Assist Pipeline entities."""
326
499
327
- _attr_has_entity_name = True
328
- _attr_should_poll = False
329
-
330
500
def __init__ (self , entry_data : RuntimeEntryData ) -> None :
331
501
"""Initialize the binary sensor."""
332
- self ._entry_data : RuntimeEntryData = entry_data
502
+ self ._entry_data = entry_data
333
503
assert entry_data .device_info is not None
334
504
device_info = entry_data .device_info
335
505
self ._device_info = device_info
0 commit comments