Skip to content

Commit 5a484f5

Browse files
committed
Stabilize Alarmo alarm entity unique IDs and avoid ID collisions
1 parent ae3caca commit 5a484f5

1 file changed

Lines changed: 125 additions & 53 deletions

File tree

custom_components/alarmo/alarm_control_panel.py

Lines changed: 125 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
ATTR_CODE_FORMAT,
1919
STATE_UNAVAILABLE,
2020
)
21-
from homeassistant.helpers import entity_platform
22-
from homeassistant.exceptions import HomeAssistantError
21+
from homeassistant.helpers import entity_platform
22+
from homeassistant.helpers import entity_registry as er
23+
from homeassistant.exceptions import HomeAssistantError
2324
from homeassistant.helpers.event import (
2425
async_call_later,
2526
async_track_point_in_time,
@@ -45,10 +46,53 @@
4546
_LOGGER = logging.getLogger(__name__)
4647

4748
# Store per-config-entry unsubscribe callbacks for platform-level dispatcher listeners
48-
PLATFORM_UNSUBS = "platform_unsubs"
49-
50-
51-
async def async_setup(hass, config):
49+
PLATFORM_UNSUBS = "platform_unsubs"
50+
51+
52+
def _build_unique_id(hass: HomeAssistant, area_id: str | None = None) -> str:
53+
"""Build a stable unique ID for Alarmo entities."""
54+
coordinator_id = hass.data[const.DOMAIN]["coordinator"].id
55+
suffix = area_id if area_id else "master"
56+
return f"{coordinator_id}_{suffix}"
57+
58+
59+
def _get_available_entity_id(
60+
hass: HomeAssistant, suggested_entity_id: str, exclude_entity_id: str | None = None
61+
) -> str:
62+
"""Return an entity_id that does not collide with existing entities."""
63+
used_ids = set(hass.states.async_entity_ids(PLATFORM))
64+
entity_registry = er.async_get(hass)
65+
used_ids.update(
66+
entity_id
67+
for entity_id in entity_registry.entities
68+
if entity_id.startswith(f"{PLATFORM}.")
69+
)
70+
71+
areas = hass.data.get(const.DOMAIN, {}).get("areas", {})
72+
for alarm_entity in areas.values():
73+
existing_id = getattr(alarm_entity, "entity_id", None)
74+
if existing_id:
75+
used_ids.add(existing_id)
76+
77+
master = hass.data.get(const.DOMAIN, {}).get("master")
78+
if master and getattr(master, "entity_id", None):
79+
used_ids.add(master.entity_id)
80+
81+
if exclude_entity_id:
82+
used_ids.discard(exclude_entity_id)
83+
84+
if suggested_entity_id not in used_ids:
85+
return suggested_entity_id
86+
87+
index = 2
88+
while True:
89+
candidate = f"{suggested_entity_id}_{index}"
90+
if candidate not in used_ids:
91+
return candidate
92+
index += 1
93+
94+
95+
async def async_setup(hass, config):
5296
"""Track states and offer events for alarm_control_panel."""
5397
return True
5498

@@ -61,54 +105,72 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
61105
async def async_setup_entry(hass, config_entry, async_add_devices):
62106
"""Set up the Alarmo entities."""
63107

64-
@callback
65-
def async_add_alarm_entity(config: dict):
66-
"""Add each entity as Alarm Control Panel."""
67-
entity_id = f"{PLATFORM}.{slugify(config['name'])}"
68-
69-
# Guard against duplicate registration (reloads/upgrade timing)
70-
if config["area_id"] in hass.data[const.DOMAIN]["areas"]:
71-
existing = hass.data[const.DOMAIN]["areas"][config["area_id"]]
72-
if existing and getattr(existing, "entity_id", None) == entity_id:
108+
@callback
109+
def async_add_alarm_entity(config: dict):
110+
"""Add each entity as Alarm Control Panel."""
111+
unique_id = _build_unique_id(hass, config["area_id"])
112+
entity_registry = er.async_get(hass)
113+
existing_entity_id = entity_registry.async_get_entity_id(
114+
PLATFORM, const.DOMAIN, unique_id
115+
)
116+
suggested_entity_id = f"{PLATFORM}.{slugify(config['name'])}"
117+
entity_id = existing_entity_id or _get_available_entity_id(
118+
hass, suggested_entity_id
119+
)
120+
121+
# Guard against duplicate registration (reloads/upgrade timing)
122+
if config["area_id"] in hass.data[const.DOMAIN]["areas"]:
123+
existing = hass.data[const.DOMAIN]["areas"][config["area_id"]]
124+
if existing and getattr(existing, "entity_id", None) == entity_id:
73125
_LOGGER.debug(
74126
"Area %s already registered as %s; skipping duplicate add",
75127
config["area_id"],
76128
entity_id,
77129
)
78130
return
79131

80-
alarm_entity = AlarmoAreaEntity(
81-
hass=hass,
82-
entity_id=entity_id,
83-
name=config["name"],
84-
area_id=config["area_id"],
85-
)
132+
alarm_entity = AlarmoAreaEntity(
133+
hass=hass,
134+
entity_id=entity_id,
135+
unique_id=unique_id,
136+
name=config["name"],
137+
area_id=config["area_id"],
138+
)
86139
hass.data[const.DOMAIN]["areas"][config["area_id"]] = alarm_entity
87140
async_add_devices([alarm_entity])
88141

89142
unsub_area = async_dispatcher_connect(
90143
hass, "alarmo_register_entity", async_add_alarm_entity
91144
)
92145

93-
@callback
94-
def async_add_alarm_master(config: dict):
95-
"""Add each entity as Alarm Control Panel."""
96-
entity_id = f"{PLATFORM}.{slugify(config['name'])}"
97-
98-
# Guard against duplicate master registration
99-
if hass.data[const.DOMAIN]["master"] is not None:
100-
existing = hass.data[const.DOMAIN]["master"]
101-
if existing and getattr(existing, "entity_id", None) == entity_id:
146+
@callback
147+
def async_add_alarm_master(config: dict):
148+
"""Add each entity as Alarm Control Panel."""
149+
unique_id = _build_unique_id(hass)
150+
entity_registry = er.async_get(hass)
151+
existing_entity_id = entity_registry.async_get_entity_id(
152+
PLATFORM, const.DOMAIN, unique_id
153+
)
154+
suggested_entity_id = f"{PLATFORM}.{slugify(config['name'])}"
155+
entity_id = existing_entity_id or _get_available_entity_id(
156+
hass, suggested_entity_id
157+
)
158+
159+
# Guard against duplicate master registration
160+
if hass.data[const.DOMAIN]["master"] is not None:
161+
existing = hass.data[const.DOMAIN]["master"]
162+
if existing and getattr(existing, "entity_id", None) == entity_id:
102163
_LOGGER.debug(
103164
"Master already registered as %s; skipping duplicate add", entity_id
104165
)
105166
return
106167

107-
alarm_entity = AlarmoMasterEntity(
108-
hass=hass,
109-
entity_id=entity_id,
110-
name=config["name"],
111-
)
168+
alarm_entity = AlarmoMasterEntity(
169+
hass=hass,
170+
entity_id=entity_id,
171+
unique_id=unique_id,
172+
name=config["name"],
173+
)
112174
hass.data[const.DOMAIN]["master"] = alarm_entity
113175
async_add_devices([alarm_entity])
114176

@@ -155,13 +217,16 @@ async def async_unload_entry(hass, config_entry):
155217
return True
156218

157219

158-
class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity):
220+
class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity):
159221
"""Defines a base alarm_control_panel entity."""
160222

161-
def __init__(self, hass: HomeAssistant, name: str, entity_id: str) -> None:
162-
"""Initialize the alarm_control_panel entity."""
163-
self.entity_id = entity_id
164-
self._name = name
223+
def __init__(
224+
self, hass: HomeAssistant, name: str, entity_id: str, unique_id: str
225+
) -> None:
226+
"""Initialize the alarm_control_panel entity."""
227+
self.entity_id = entity_id
228+
self._attr_unique_id = unique_id
229+
self._name = name
165230
self._state = None
166231
self.hass = hass
167232
self._config = {}
@@ -191,9 +256,9 @@ def device_info(self) -> dict:
191256
}
192257

193258
@property
194-
def unique_id(self):
195-
"""Return a unique ID to use for this entity."""
196-
return f"{self.entity_id}"
259+
def unique_id(self):
260+
"""Return a unique ID to use for this entity."""
261+
return self._attr_unique_id
197262

198263
@property
199264
def name(self):
@@ -698,14 +763,19 @@ async def async_will_remove_from_hass(self):
698763
)
699764

700765

701-
class AlarmoAreaEntity(AlarmoBaseEntity):
766+
class AlarmoAreaEntity(AlarmoBaseEntity):
702767
"""Defines a base alarm_control_panel entity."""
703768

704-
def __init__(
705-
self, hass: HomeAssistant, name: str, entity_id: str, area_id: str
706-
) -> None:
707-
"""Initialize the alarm_control_panel entity."""
708-
super().__init__(hass, name, entity_id)
769+
def __init__(
770+
self,
771+
hass: HomeAssistant,
772+
name: str,
773+
entity_id: str,
774+
unique_id: str,
775+
area_id: str,
776+
) -> None:
777+
"""Initialize the alarm_control_panel entity."""
778+
super().__init__(hass, name, entity_id, unique_id)
709779

710780
self.area_id = area_id
711781
self._timer = None
@@ -1169,12 +1239,14 @@ def update_ready_to_arm_modes(self, value):
11691239
)
11701240

11711241

1172-
class AlarmoMasterEntity(AlarmoBaseEntity):
1242+
class AlarmoMasterEntity(AlarmoBaseEntity):
11731243
"""Defines a base alarm_control_panel entity."""
11741244

1175-
def __init__(self, hass: HomeAssistant, name: str, entity_id: str) -> None:
1176-
"""Initialize the alarm_control_panel entity."""
1177-
super().__init__(hass, name, entity_id)
1245+
def __init__(
1246+
self, hass: HomeAssistant, name: str, entity_id: str, unique_id: str
1247+
) -> None:
1248+
"""Initialize the alarm_control_panel entity."""
1249+
super().__init__(hass, name, entity_id, unique_id)
11781250
self.area_id = None
11791251
self._target_state = None
11801252

0 commit comments

Comments
 (0)