Skip to content

Commit 93f4a1e

Browse files
committed
fix(zones): re-link existing zone entities to the device of their current type
Home Assistant links an entity to a device - and derives its entity id from that device's name - only on first registration, so a zone entity created while the zone still had another type kept the old device and an entity id naming it (a door zone sitting on Crow Alarm Windows as binary_sensor.crow_alarm_windows_terrassentur, for example). binary_sensor.async_setup_entry now re-derives the device and, when the entity id still carries another zone type's device name, rebuilds that id too, before the entities are created. A user-chosen entity id is moved but never renamed; corrections are logged. Adds CrowRuntimeData.entry_id, a shared zone_device_info()/zone_unique_id() helper and harness coverage for the repair.
1 parent 5f1989b commit 93f4a1e

6 files changed

Lines changed: 333 additions & 23 deletions

File tree

‎CHANGELOG.md‎

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

55
### 🔧 Fixed
66

7+
* **Existing zone entities now follow their zone's type.** Home Assistant links an
8+
entity to a device - and derives its entity id from that device's name - only when
9+
the entity is registered for the **first time**. An entity created while a zone
10+
still had another type therefore kept the old device *and* an entity id naming it,
11+
so a door zone could sit on the *Crow Alarm Windows* device as
12+
`binary_sensor.crow_alarm_windows_terrassentur` no matter what the config flow said.
13+
On every setup the integration now re-derives the device **and** the entity id from
14+
the configured type, so the zone ends up on its own type's device with a matching
15+
entity id (`binary_sensor.crow_alarm_doors_terrassentur`). The entity id is only
16+
rebuilt when it still carries another zone type's device name, so an id you chose
17+
yourself is never overwritten; every correction is logged as an info line.
718
* **Zone sub-devices are grouped per zone type again.** A zone is now registered on
819
the device that matches its **configured type**: `window` -> *Crow Alarm Windows*,
920
`door` -> *Crow Alarm Doors*, `motion` -> *Crow Alarm Motion*, `smoke` ->
@@ -30,11 +41,9 @@
3041
strips the different aspect ratio leaves over are filled with a continuation of the
3142
banner's own background (the edge band is mirrored and faded towards the rim).
3243

33-
> **Note after updating:** if a zone's type was changed in an earlier release, its
34-
> entity id was fixed when it was first created and is not renamed by Home Assistant.
35-
> Rename the entity once under **Settings > Devices & Services > Entities** (or re-add
36-
> the zone) if its entity id still names the old type. An empty *Crow Alarm Sensors*
37-
> device left over from 2.1.1 can simply be deleted.
44+
> **After updating:** reload the integration (or restart Home Assistant) once so the
45+
> zone entities are re-registered and the repair above runs. An empty *Crow Alarm
46+
> Sensors* device left over from 2.1.1 can simply be deleted.
3847
3948
## [2.1.1] - 2026-10-09
4049

‎custom_components/crowipmodule/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
7474
fw_version = entry.data.get(CONF_FW_VERSION, DEFAULT_FW_VERSION)
7575
fw_date = entry.data.get(CONF_FW_DATE, DEFAULT_FW_DATE)
7676
entry.runtime_data = CrowRuntimeData(
77-
controller=controller, firmware=f"{fw_version} ({fw_date})"
77+
controller=controller,
78+
entry_id=entry.entry_id,
79+
firmware=f"{fw_version} ({fw_date})",
7880
)
7981

8082
# The panel client runs in its own thread, so every callback has to hop back

‎custom_components/crowipmodule/binary_sensor.py‎

Lines changed: 142 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
BinarySensorEntity,
1010
)
1111
from homeassistant.config_entries import ConfigEntry
12-
from homeassistant.const import CONF_HOST, EntityCategory
12+
from homeassistant.const import CONF_HOST, EntityCategory, Platform
1313
from homeassistant.core import HomeAssistant, callback
14+
from homeassistant.helpers import device_registry as dr, entity_registry as er
1415
from homeassistant.helpers.device_registry import DeviceInfo
1516
from homeassistant.helpers.dispatcher import async_dispatcher_connect
1617
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
18+
from homeassistant.util import slugify
1719

1820
from .const import (
1921
CONF_OBJ_BATTERY,
@@ -25,6 +27,7 @@
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,
@@ -48,6 +51,137 @@
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+
51185
async 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

189316
class CrowSystemStatusSensor(CrowBaseEntity):

‎custom_components/crowipmodule/device.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class CrowRuntimeData:
2424
"""Objects shared between a config entry and its entity platforms."""
2525

2626
controller: CrowIPAlarmPanel
27+
#: Config entry id of the entry the runtime data belongs to. Used to scope
28+
#: entity registry lookups to this entry.
29+
entry_id: str | None = None
2730
#: Device registry id of the main panel, used as ``via_device_id`` for the
2831
#: zone sub-devices. ``None`` while the hub device has not been registered.
2932
device_id: str | None = None

‎docs/SDD.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,12 @@ no code available it logs an error and sends nothing, rather than emitting a dig
461461

462462
| Aspect | Value |
463463
|---|---|
464-
| `unique_id` | `crow_zone_{n}` |
464+
| `unique_id` | `crow_zone_{n}` (`zone_unique_id(n)`) |
465465
| `name` | zone name from options |
466466
| `device_class` | from `_ZONE_DEVICE_CLASSES` (below) |
467467
| `is_on` | `zone_state[n]["status"]["open"]` |
468468
| `extra_state_attributes` | the zone `status` dict (`open`, `bypass`, `alarm`, `tamper`) |
469-
| `device_info` | the device of the zone's **configured type** (`ZONE_DEVICES`), with `via_device_id` |
469+
| `device_info` | `zone_device_info()`: the device of the zone's **configured type** (`ZONE_DEVICES`), with `via_device_id` |
470470

471471
| Zone type | `BinarySensorDeviceClass` |
472472
|---|---|
@@ -490,6 +490,29 @@ that one device, so a smoke zone and a motion zone shared it.) Comparing against
490490
bare string) is deliberate: `BinarySensorDeviceClass` is a `StrEnum`, so the comparison holds
491491
either way, but the enum form survives refactors.
492492

493+
#### 7.2.1 Zone registry alignment (`_async_align_zone_entities`)
494+
495+
Home Assistant links an entity to a device — and derives the entity id from that device's name —
496+
**only when the entity is registered for the first time**. A later `device_info` change updates
497+
`device_id`, but the entity id keeps naming the device the entity was born on. A zone whose type
498+
was changed after its entity was created therefore ended up on the right device with an id naming
499+
the old one (`binary_sensor.crow_alarm_windows_terrassentur` for a door zone).
500+
501+
Before the zone entities are created, `async_setup_entry` therefore walks `options["zones"]` and,
502+
for every existing registry entry (`unique_id` = `zone_unique_id(n)`, scoped to the entry via
503+
`runtime_data.entry_id`):
504+
505+
1. creates/gets the device of the type the zone has **now** and writes it as the entry's
506+
`device_id`;
507+
2. if the entity id still starts with another zone type's device name
508+
(`_entity_id_was_generated_for_another_type`), rebuilds it with
509+
`entity_registry.async_regenerate_entity_id()` and renames the entry.
510+
511+
An id the user chose themselves never matches another zone device's prefix, so it is moved but
512+
never renamed. Every correction is logged at info level. `zone_device_info()` is the single
513+
constructor for a zone device's `DeviceInfo`, shared by the entity and the alignment, so the two
514+
cannot disagree.
515+
493516
**`CrowSystemStatusSensor`** — six diagnostic sensors.
494517

495518
| `unique_id` | Name | Device class | `is_on` |

0 commit comments

Comments
 (0)