99 BinarySensorEntity ,
1010)
1111from homeassistant .config_entries import ConfigEntry
12- from homeassistant .const import CONF_HOST , EntityCategory
12+ from homeassistant .const import CONF_HOST , EntityCategory , Platform
1313from homeassistant .core import HomeAssistant , callback
14+ from homeassistant .helpers import device_registry as dr , entity_registry as er
1415from homeassistant .helpers .device_registry import DeviceInfo
1516from homeassistant .helpers .dispatcher import async_dispatcher_connect
1617from homeassistant .helpers .entity_platform import AddConfigEntryEntitiesCallback
18+ from homeassistant .util import slugify
1719
1820from .const import (
1921 CONF_OBJ_BATTERY ,
2527 CONF_ZONES ,
2628 DEVICE_NAME ,
2729 DEVICE_NAME_ZONE_OTHER ,
30+ DOMAIN ,
2831 IDENTIFIER_ZONE_OTHER ,
2932 MODEL_IP_MODULE_ZONE ,
3033 SIGNAL_CONNECTION_UPDATE ,
4851 "safety" : BinarySensorDeviceClass .SAFETY ,
4952}
5053
54+
55+ def zone_unique_id (zone_number : int ) -> str :
56+ """Return the unique id of a zone's binary sensor.
57+
58+ Frozen - the registry alignment below looks zone entities up by it.
59+ """
60+ return f"crow_zone_{ zone_number } "
61+
62+
63+ def zone_device_name (zone_type : str ) -> str :
64+ """Return the device name a zone of this type is grouped into."""
65+ return ZONE_DEVICES .get (zone_type , (IDENTIFIER_ZONE_OTHER , DEVICE_NAME_ZONE_OTHER ))[1 ]
66+
67+
68+ def zone_device_info (
69+ runtime_data : CrowRuntimeData , host : str , zone_type : str
70+ ) -> DeviceInfo :
71+ """Return the ``DeviceInfo`` of the device a zone type is grouped into.
72+
73+ Both the entity and the registry alignment below build their device from
74+ here, so an entity and its registry entry can never disagree about it.
75+ """
76+ identifier , name = ZONE_DEVICES .get (
77+ zone_type , (IDENTIFIER_ZONE_OTHER , DEVICE_NAME_ZONE_OTHER )
78+ )
79+ return build_device_info (
80+ name = name ,
81+ host = host ,
82+ identifier = identifier ,
83+ model = MODEL_IP_MODULE_ZONE ,
84+ sw_version = runtime_data .firmware ,
85+ via_device_id = runtime_data .device_id ,
86+ )
87+
88+
89+ def _entity_id_was_generated_for_another_type (entity_id : str , zone_type : str ) -> bool :
90+ """Return whether an entity id still names another zone type's device.
91+
92+ An entity id is ``<domain>.<device name> <entity name>``, so one that was
93+ generated while the zone still had a different type still starts with that
94+ type's device name.
95+ """
96+ object_id = entity_id .partition ("." )[2 ]
97+ current = slugify (zone_device_name (zone_type ))
98+ for name in (_all_zone_device_names ()):
99+ if name != current and object_id .startswith (f"{ name } _" ):
100+ return True
101+ return False
102+
103+
104+ def _all_zone_device_names () -> set [str ]:
105+ """Return the slugified device names of every zone group plus the fallback."""
106+ names = {slugify (name ) for _ , name in ZONE_DEVICES .values ()}
107+ names .add (slugify (DEVICE_NAME_ZONE_OTHER ))
108+ return names
109+
110+
111+ @callback
112+ def _async_align_zone_entities (
113+ hass : HomeAssistant ,
114+ runtime_data : CrowRuntimeData ,
115+ host : str ,
116+ configured_zones : dict ,
117+ ) -> None :
118+ """Re-link existing zone entities to the device of their configured type.
119+
120+ Home Assistant links an entity to a device - and derives its entity id from
121+ that device's name - only when the entity is registered for the very first
122+ time. A later device change moves the entity but leaves the entity id
123+ naming the old device, so a zone whose type was changed afterwards keeps an
124+ entity id of the type it once had (a door zone called
125+ ``..._windows_terrassentur``, for example).
126+
127+ Re-derive the device and the entity id from the configured type here, before
128+ the entities are created, so both always match the type the zone has now.
129+ """
130+ registry = er .async_get (hass )
131+ devices = dr .async_get (hass )
132+
133+ for zone_key , zone_info in configured_zones .items ():
134+ try :
135+ zone_number = int (zone_key )
136+ except (TypeError , ValueError ):
137+ continue
138+
139+ unique_id = zone_unique_id (zone_number )
140+ entity_id = registry .async_get_entity_id (
141+ Platform .BINARY_SENSOR , DOMAIN , unique_id
142+ )
143+ if entity_id is None :
144+ continue
145+
146+ entry = registry .async_get (entity_id )
147+ if entry is None or entry .config_entry_id != runtime_data .entry_id :
148+ continue
149+
150+ zone_type = zone_info .get ("type" )
151+ device = devices .async_get_or_create (
152+ config_entry_id = entry .config_entry_id ,
153+ ** zone_device_info (runtime_data , host , zone_type ),
154+ )
155+ entry = registry .async_update_entity (entity_id , device_id = device .id )
156+
157+ if not _entity_id_was_generated_for_another_type (entry .entity_id , zone_type ):
158+ continue
159+
160+ expected_id = registry .async_regenerate_entity_id (entry )
161+ if expected_id == entry .entity_id :
162+ continue
163+
164+ try :
165+ registry .async_update_entity (entry .entity_id , new_entity_id = expected_id )
166+ except ValueError as err : # noqa: PERF203 - one bad zone must not stop the rest
167+ _LOGGER .warning (
168+ "Could not rename zone entity %s to %s: %s" ,
169+ entry .entity_id ,
170+ expected_id ,
171+ err ,
172+ )
173+ continue
174+
175+ _LOGGER .info (
176+ "Zone %s is a %s now: moved %s to %s and renamed it to %s" ,
177+ zone_number ,
178+ zone_type ,
179+ entry .entity_id ,
180+ device .name ,
181+ expected_id ,
182+ )
183+
184+
51185async def async_setup_entry (
52186 hass : HomeAssistant ,
53187 entry : ConfigEntry ,
@@ -67,11 +201,15 @@ async def async_setup_entry(
67201 for i in range (1 , 17 ):
68202 configured_zones [str (i )] = {"name" : f"Zone { i } " , "type" : "window" }
69203
204+ # Bring zone entities that were registered under an earlier type onto the
205+ # device of the type the zone has now (before they are added again).
206+ _async_align_zone_entities (hass , runtime_data , host , configured_zones )
207+
70208 for zone_num_str , zone_info in configured_zones .items ():
71209 try :
72210 zone_num = int (zone_num_str )
73211 entities .append (CrowZoneSensor (
74- runtime_data , host , zone_num , zone_info ["name" ], zone_info [ "type" ]
212+ runtime_data , host , zone_num , zone_info ["name" ], zone_info . get ( "type" )
75213 ))
76214 except ValueError :
77215 _LOGGER .warning ("Skipping invalid zone config key: %s" , zone_num_str )
@@ -128,7 +266,7 @@ def __init__(self, runtime_data, host, zone_number, zone_name, zone_type):
128266 self ._attr_device_class = _ZONE_DEVICE_CLASSES .get (zone_type )
129267 if self ._attr_device_class is None and zone_type :
130268 _LOGGER .warning ("Unknown zone type '%s' for zone %s; no device class set" , zone_type , zone_number )
131- self ._attr_unique_id = f"crow_zone_ { zone_number } "
269+ self ._attr_unique_id = zone_unique_id ( zone_number )
132270 self ._info = runtime_data .controller .zone_state .get (
133271 zone_number , {"status" : {"open" : False }}
134272 )
@@ -172,18 +310,7 @@ def device_info(self) -> DeviceInfo:
172310 The identifier tuple based ``via_device`` used before is deprecated in
173311 Home Assistant and is removed in 2027.8.
174312 """
175- device_identifier , device_name = ZONE_DEVICES .get (
176- self ._zone_type , (IDENTIFIER_ZONE_OTHER , DEVICE_NAME_ZONE_OTHER )
177- )
178-
179- return build_device_info (
180- name = device_name ,
181- host = self ._host ,
182- identifier = device_identifier ,
183- model = MODEL_IP_MODULE_ZONE ,
184- sw_version = self ._runtime_data .firmware ,
185- via_device_id = self ._runtime_data .device_id ,
186- )
313+ return zone_device_info (self ._runtime_data , self ._host , self ._zone_type )
187314
188315
189316class CrowSystemStatusSensor (CrowBaseEntity ):
0 commit comments