Coverage for custom_components/supernotify/people.py: 95%
277 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
1from __future__ import annotations
3import logging
4from typing import TYPE_CHECKING, Any
6import homeassistant.util.dt as dt_util
7from homeassistant.components.notify import (
8 NotifyEntity,
9 NotifyEntityFeature,
10)
11from homeassistant.components.person.const import DOMAIN as PERSON_DOMAIN
12from homeassistant.const import (
13 ATTR_ENTITY_ID,
14 ATTR_FRIENDLY_NAME,
15 CONF_ALIAS,
16 CONF_EMAIL,
17 CONF_ENABLED,
18 CONF_TARGET,
19 STATE_HOME,
20 STATE_NOT_HOME,
21)
22from homeassistant.core import callback
23from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
24from homeassistant.helpers.redact import partial_redact
25from homeassistant.util import slugify
27from .common import ensure_list
28from .const import (
29 ATTR_ALIAS,
30 ATTR_EMAIL,
31 ATTR_ENABLED,
32 ATTR_MOBILE_APP_ID,
33 ATTR_PERSON_ID,
34 ATTR_PHONE,
35 ATTR_USER_ID,
36 CONF_DATA,
37 CONF_DELIVERY,
38 CONF_MOBILE_APP_ID,
39 CONF_MOBILE_DEVICES,
40 CONF_MOBILE_DISCOVERY,
41 CONF_PERSON,
42 CONF_PHONE_NUMBER,
43 CONF_USER_ID,
44 OCCUPANCY_ALL,
45 OCCUPANCY_ALL_IN,
46 OCCUPANCY_ALL_OUT,
47 OCCUPANCY_ANY_IN,
48 OCCUPANCY_ANY_OUT,
49 OCCUPANCY_NONE,
50 OCCUPANCY_ONLY_IN,
51 OCCUPANCY_ONLY_OUT,
52)
53from .model import DeliveryCustomization, NotifyEntityPlatform, Target
55if TYPE_CHECKING:
56 from homeassistant.core import Context, State
58 from .binary_sensor import SupernotifyRecipientBinarySensor
59 from .hass_api import HomeAssistantAPI, TrackedDeviceDetails
62_LOGGER = logging.getLogger(__name__)
65class RecipientNotifyEntity(NotifyEntity):
66 """Expose a single recipient as its own `notify.recipient_<name>` entity.
68 Sent as an ordinary `target` on the main supernotify action, just like any other entity -
69 Notification recognizes it as one of supernotify's own published recipient notify entities
70 and resolves it to this recipient, still going through the full default-recipient delivery pipeline
71 (occupancy, scenarios, personal delivery overrides, dedupe, snooze) rather than a literal,
72 unscoped target override. Per HA's notify entity service schema, only message/title are
73 ever passed in here - no data/target.
74 """
76 _attr_has_entity_name = True
77 _attr_translation_key = "recipient"
79 def __init__(
80 self,
81 unique_id: str,
82 recipient: Recipient,
83 engine: NotifyEntityPlatform,
84 ) -> None:
85 """Initialize the recipient notify entity."""
86 self._attr_unique_id = unique_id
87 self._attr_name = recipient.alias or recipient.name
88 self._attr_supported_features = NotifyEntityFeature.TITLE
89 self._recipient = recipient
90 self._engine = engine
91 self.entity_id = f"notify.recipient_{recipient.name}"
92 # Fallback attribute for HA < 2026.3 (python_full_version < '3.14.2' in pyproject.toml
93 # pins homeassistant==2026.2.3) - NotifyEntity there has no _async_record_notification()
94 # and no other supported way to set its native `state` from outside a direct
95 # notify.recipient_<name> service call (the logic lives in the @final, HA-framework-only
96 # _async_send_message()). Left unused - and extra_state_attributes returns None - once
97 # running against a HA version that has _async_record_notification(); see
98 # record_notification() below for the runtime hasattr() check that picks a path. Support
99 # for the pre-2026.3 lane is time-limited: see the "python_313_deprecated" repair issue,
100 # dropped when HA 2026.10 ships - this whole fallback (and the property below) can go
101 # once that lane is gone.
102 self._last_notified: str | None = None
104 @property
105 def extra_state_attributes(self) -> dict[str, Any] | None:
106 """Only populated on the pre-2026.3 fallback path - see record_notification()."""
107 if hasattr(self, "_async_record_notification"):
108 return None
109 return {"last_notified": self._last_notified}
111 def record_notification(self, context: Context | None = None) -> None:
112 """Record that this recipient was notified via supernotify.notify's main pipeline
113 (any target - person_id, email, mobile device...), not just via a direct call to
114 this notify.recipient_<name> entity. Called from Notification.record_result().
116 Delegates to NotifyEntity._async_record_notification() when available - the same
117 HA-native method html5/mobile_app call on a direct notify.recipient_<name> service
118 call - so `state` reflects the most recent delivery via either path. On HA versions
119 before 2026.3 that method doesn't exist (see the docstring on self._last_notified in
120 __init__), so this falls back to the pre-refactor design: a separate extra_state_attributes
121 attribute, restored manually in async_added_to_hass() below. Either way, a message routed
122 through supernotify.notify's main pipeline (the common case) never invokes this entity's
123 own async_send_message() - see convert_notify_entities() in notification.py, which
124 short-circuits that target straight to a person_id to avoid calling back into this same
125 entity in a loop - so without this explicit call, delivery via that pipeline would never
126 be reflected here at all.
128 `context` is the calling HA service context, if any, so the state change is attributed to
129 the action call (or automation, or user) that caused it, in the logbook and history, just
130 as it would be for a direct notify.recipient_<name> service call."""
131 if context is not None:
132 self.async_set_context(context)
133 if hasattr(self, "_async_record_notification"):
134 self._async_record_notification()
135 else:
136 self._last_notified = dt_util.utcnow().isoformat()
137 self.async_write_ha_state()
139 async def async_added_to_hass(self) -> None:
140 await super().async_added_to_hass()
141 self._recipient.notify_entity_id = self.entity_id
142 self._recipient.notify_entity = self
143 # Restoring `state` after a restart is handled for us by
144 # NotifyEntity.async_internal_added_to_hass() when _async_record_notification() is
145 # available. On the pre-2026.3 fallback path there's no such restore, so do it manually
146 # here, same as before the refactor.
147 if not hasattr(self, "_async_record_notification"):
148 last_state = await self.async_get_last_state()
149 if last_state is not None:
150 self._last_notified = last_state.attributes.get("last_notified")
152 async def async_will_remove_from_hass(self) -> None:
153 self._recipient.notify_entity_id = None
154 self._recipient.notify_entity = None
155 await super().async_will_remove_from_hass()
157 async def async_send_message(self, message: str, title: str | None = None) -> None:
158 """Send a message to this recipient."""
159 await self._engine.async_send_message(message, title=title, target=self.entity_id, context=self._context)
162class Recipient:
163 """Recipient to distinguish from the native HA Person.
165 The "future native entity use" this class was once staged for (BinarySensorDeviceClass,
166 EntityCategory, etc.) has arrived as SupernotifyRecipientBinarySensor in binary_sensor.py -
167 a wrapper Entity holding a reference to a Recipient, the same composition already used for
168 RecipientNotifyEntity above, rather than this plain domain object inheriting from Entity.
169 """
171 def __init__(self, config: dict[str, Any] | None, default_mobile_discovery: bool = True) -> None:
172 config = config or {}
173 self.alias: str | None = config.get(CONF_ALIAS)
174 self.email: str | None = config.get(CONF_EMAIL)
175 self.phone_number: str | None = config.get(CONF_PHONE_NUMBER)
176 self.user_id: str | None = config.get(CONF_USER_ID)
177 # A recipient normally is a native HA Person, but CONF_PERSON is optional - a household
178 # that only sets up Users (no Person records) still has a working, addressable recipient
179 # via CONF_USER_ID alone. entity_id is then a synthetic, never-registered id (`user.<name>`
180 # rather than `person.<name>` - both kinds of config are still "recipients", so that word
181 # alone wouldn't distinguish them), used purely as this recipient's internal key (person_id
182 # target category, notify/binary_sensor/switch entity naming, snooze/delivery-override
183 # lookups) - see also the "impossible for a real HA entity to exist" cases these lookups
184 # already handle gracefully, e.g. PeopleRegistry person_attributes()/
185 # _fetch_person_entity_state() returning None for an unknown entity_id.
186 self.entity_id: str = config.get(CONF_PERSON) or ""
187 # Without a Person there's no presence, so the recipient is left out of occupancy
188 self.has_person: bool = bool(self.entity_id)
189 if self.entity_id:
190 self.name: str = self.entity_id.replace("person.", "")
191 else:
192 self.name = slugify(self.alias or self.user_id or "")
193 self.entity_id = f"user.{self.name}"
194 self.notify_entity_id: str | None = None
195 # Set/cleared by RecipientNotifyEntity.async_added_to_hass()/async_will_remove_from_hass()
196 # - the live entity object itself, so record_notification() can be called directly from
197 # Notification.record_result() without a registry lookup by entity_id.
198 self.notify_entity: RecipientNotifyEntity | None = None
200 self._target: Target = Target(config.get(CONF_TARGET, {}), target_data=config.get(CONF_DATA))
201 self.delivery_overrides: dict[str, DeliveryCustomization] = {
202 k: DeliveryCustomization(config=v, target_specific=True) for k, v in config.get(CONF_DELIVERY, {}).items()
203 }
204 self.enabled: bool = config.get(CONF_ENABLED, True)
205 # as configured, which enabled can be overridden from at runtime by the recipient switch
206 self.config_enabled: bool = self.enabled
207 self.mobile_discovery: bool = config.get(CONF_MOBILE_DISCOVERY, default_mobile_discovery)
208 self.mobile_devices: dict[str, dict[str, str | list[str] | None]] = {
209 c[CONF_MOBILE_APP_ID]: c for c in config.get(CONF_MOBILE_DEVICES, [])
210 }
211 self.disabled_mobile_app_ids: list[str] = [k for k, v in self.mobile_devices.items() if not v.get(CONF_ENABLED, True)]
212 _LOGGER.debug("SUPERNOTIFY Recipient config %s -> %s", config, self.as_dict(redact=True))
214 def initialize(self, people_registry: PeopleRegistry) -> None:
216 self._target.extend(ATTR_PERSON_ID, [self.entity_id])
217 if self.email:
218 self._target.extend(ATTR_EMAIL, self.email)
219 if self.phone_number:
220 self._target.extend(ATTR_PHONE, self.phone_number)
221 if self.mobile_discovery:
222 # a known user_id (explicitly configured, or already backfilled below from a prior
223 # initialize()) resolves devices directly, without needing a Person entity at all
224 discovered_devices: list[TrackedDeviceDetails] = (
225 people_registry.mobile_devices_for_user(self.user_id)
226 if self.user_id
227 else people_registry.mobile_devices_for_person(self.entity_id)
228 )
229 if discovered_devices:
230 new_ids = []
231 for d in discovered_devices:
232 if d.mobile_app_id in self.mobile_devices:
233 # merge with manual registrations, with priority to manually overridden values
234 merged = d.as_dict()
235 merged.update(self.mobile_devices[d.mobile_app_id])
236 self.mobile_devices[d.mobile_app_id] = merged
237 new_ids.append(d.mobile_app_id)
238 _LOGGER.debug("SUPERNOTIFY Updating %s mobile device %s from registry", self.entity_id, d.mobile_app_id)
239 elif d.mobile_app_id is not None:
240 self.mobile_devices[d.mobile_app_id] = d.as_dict()
241 new_ids.append(d.mobile_app_id)
242 _LOGGER.info(
243 "SUPERNOTIFY Auto configured %s for mobile devices %s",
244 self.entity_id,
245 ",".join(new_ids),
246 )
247 else:
248 _LOGGER.info("SUPERNOTIFY Unable to find mobile devices for %s", self.entity_id)
249 if self.mobile_devices:
250 self._target.extend(ATTR_MOBILE_APP_ID, list(self.enabled_mobile_devices.keys()))
251 if not self.user_id or not self.alias:
252 attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id)
253 if attrs:
254 if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str):
255 self.user_id = attrs.get(ATTR_USER_ID)
256 if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str):
257 self.alias = attrs.get(ATTR_ALIAS)
258 if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str):
259 self.alias = attrs.get(ATTR_FRIENDLY_NAME)
260 _LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id)
261 else:
262 _LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id)
263 _LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True))
265 def on_notification(self, context: Context | None = None) -> None:
266 # Record that a notification has occurred for this person
267 if self.notify_entity is not None:
268 self.notify_entity.record_notification(context)
270 @property
271 def enabled_mobile_devices(self) -> dict[str, dict[str, str | list[str] | None]]:
272 return {k: v for k, v in self.mobile_devices.items() if v.get(CONF_ENABLED, True)}
274 def enabling_delivery_names(self) -> list[str]:
275 """Explicitly overriding enabled state"""
276 return [
277 delname
278 for delname, delconf in self.delivery_overrides.items()
279 if delconf.enabled is not None and delconf.enabled is True
280 ]
282 def disabling_delivery_names(self) -> list[str]:
283 """Explicitly overriding enabled state"""
284 return [
285 delname
286 for delname, delconf in self.delivery_overrides.items()
287 if delconf.enabled is not None and delconf.enabled is False
288 ]
290 def target(self, delivery_name: str) -> Target:
291 recipient_target: Target = self._target
292 personal_delivery: DeliveryCustomization | None = self.delivery_overrides.get(delivery_name)
293 if personal_delivery and personal_delivery.enabled is not False:
294 if personal_delivery.target and personal_delivery.target.has_targets():
295 recipient_target += personal_delivery.target
296 if personal_delivery.data:
297 recipient_target += Target([], target_data=personal_delivery.data, target_specific_data=True)
298 return recipient_target
300 def as_dict(self, occupancy_only: bool = False, redact: bool = False, **_kwargs: Any) -> dict[str, Any]:
301 result = {CONF_PERSON: self.entity_id, CONF_ENABLED: self.enabled}
302 if not occupancy_only:
303 result.update({
304 CONF_ALIAS: self.alias,
305 CONF_EMAIL: self.email,
306 CONF_PHONE_NUMBER: self.phone_number,
307 ATTR_USER_ID: self.user_id,
308 CONF_MOBILE_DISCOVERY: self.mobile_discovery,
309 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()),
310 CONF_TARGET: self._target.as_dict() if self._target else None,
311 CONF_DELIVERY: {d: c.as_dict() for d, c in self.delivery_overrides.items()}
312 if self.delivery_overrides
313 else None,
314 })
315 if redact:
316 for k in (CONF_EMAIL, CONF_PHONE_NUMBER):
317 if k in result:
318 result[k] = partial_redact(result[k], unmasked_prefix=2, unmasked_suffix=1)
319 return result
321 def attributes(self) -> dict[str, Any]:
322 """For exposure as entity state"""
323 attrs: dict[str, Any] = {
324 ATTR_ENTITY_ID: self.entity_id,
325 ATTR_ENABLED: self.enabled,
326 CONF_EMAIL: self.email,
327 CONF_PHONE_NUMBER: self.phone_number,
328 ATTR_USER_ID: self.user_id,
329 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()),
330 CONF_MOBILE_DISCOVERY: self.mobile_discovery,
331 CONF_TARGET: self._target,
332 CONF_DELIVERY: self.delivery_overrides,
333 }
334 if self.alias:
335 attrs[ATTR_FRIENDLY_NAME] = self.alias
336 return attrs
339class PeopleRegistry:
340 def __init__(
341 self,
342 recipients: list[dict[str, Any]],
343 hass_api: HomeAssistantAPI,
344 discover: bool = False,
345 mobile_discovery: bool = True,
346 ) -> None:
347 self.hass_api = hass_api
348 self.people: dict[str, Recipient] = {}
349 self._recipients: list[dict[str, Any]] = ensure_list(recipients)
350 self.mobile_discovery = mobile_discovery
351 self.discover = discover
352 # Populated by binary_sensor.py's async_setup_entry once the platform is loaded - see
353 # register_entity/unregister_entity. Empty (and harmless to look up against) before
354 # then, and in tests that build PeopleRegistry directly without a config entry.
355 self._entities: dict[str, SupernotifyRecipientBinarySensor] = {}
357 async def initialize(self) -> None:
358 recipients: dict[str, dict[str, Any]] = {}
359 if self.discover:
360 entity_ids = self.find_people()
361 person_user_ids: set[str] = set()
362 if entity_ids:
363 recipients = {entity_id: {CONF_PERSON: entity_id} for entity_id in entity_ids}
364 for entity_id in entity_ids:
365 attrs = self.person_attributes(entity_id)
366 if attrs and isinstance(attrs.get(ATTR_USER_ID), str):
367 person_user_ids.add(attrs[ATTR_USER_ID])
368 _LOGGER.info("SUPERNOTIFY Auto-discovered people: %s", entity_ids)
370 # Users are the essential HA concept - mobile_app itself only requires one, a Person
371 # is a separate, optional layer (see CONF_USER_ID in const.py) - so a real user with
372 # no matching Person here is still discoverable, just without Person's presence
373 # tracking/UI. A user already represented by a Person above is not duplicated.
374 real_user_ids = await self.hass_api.async_real_user_ids()
375 user_only_ids = {uid: name for uid, name in real_user_ids.items() if uid not in person_user_ids}
376 if user_only_ids:
377 recipients.update({uid: {CONF_USER_ID: uid, CONF_ALIAS: name} for uid, name in user_only_ids.items()})
378 _LOGGER.info("SUPERNOTIFY Auto-discovered user-only accounts: %s", list(user_only_ids.values()))
380 for r in self._recipients:
381 # merge/dedupe key only - the recipient's real identity (entity_id) is derived by
382 # Recipient itself, below, from whichever of these was given
383 key = r.get(CONF_PERSON) or r.get(CONF_USER_ID)
384 if not key:
385 _LOGGER.warning("SUPERNOTIFY Skipping invalid recipient with neither 'person' nor 'user_id' key:%s", r)
386 continue
387 if key in recipients:
388 _LOGGER.debug("SUPERNOTIFY Overriding %s entity defaults from recipient config", key)
389 recipients[key].update(r)
390 else:
391 recipients[key] = r
393 for r in recipients.values():
394 recipient: Recipient = Recipient(r, default_mobile_discovery=self.mobile_discovery)
395 recipient.initialize(self)
397 self.people[recipient.entity_id] = recipient
399 def register_entity(self, name: str, entity: SupernotifyRecipientBinarySensor) -> None:
400 """Called by SupernotifyRecipientBinarySensor.async_added_to_hass()."""
401 self._entities[name] = entity
403 def unregister_entity(self, name: str) -> None:
404 """Called by SupernotifyRecipientBinarySensor.async_will_remove_from_hass()."""
405 self._entities.pop(name, None)
407 def recipient_entities(self) -> list[SupernotifyRecipientBinarySensor]:
408 """Every registered recipient binary_sensor - used by supernotify.refresh_entities."""
409 return list(self._entities.values())
411 @callback
412 def async_refresh_entity(self, name: str) -> None:
413 """Re-publish one recipient's binary_sensor now"""
414 entity = self._entities.get(name)
415 if entity is not None:
416 entity.async_write_ha_state()
418 def expose_notify_entities(
419 self, entry_id: str, async_add_entities: AddConfigEntryEntitiesCallback, service: NotifyEntityPlatform
420 ) -> None:
421 async_add_entities(
422 RecipientNotifyEntity(f"{entry_id}_recipient_{recipient.name}", recipient, service)
423 for recipient in self.people.values()
424 )
426 def person_attributes(self, entity_id: str) -> dict[str, Any] | None:
427 state: State | None = self.hass_api.get_state(entity_id)
428 if state is not None and state.attributes:
429 return state.attributes
430 return None
432 def name_for_user_id(self, user_id: str) -> str | None:
433 for recipient in self.people.values():
434 if recipient.user_id == user_id:
435 return recipient.alias or recipient.name
436 return None
438 def people_named(self, name: str) -> list[Recipient]:
439 """The recipients called this, by alias or name, ignoring case and underscores - or failing
440 that, those with it as their first name, so 'jey' finds 'Jey Burrows'"""
441 wanted: str = name.replace("_", " ").casefold().strip()
442 full_names: dict[str, set[str]] = {
443 recipient.entity_id: {n.replace("_", " ").casefold().strip() for n in (recipient.alias, recipient.name) if n}
444 for recipient in self.people.values()
445 }
446 if exact := [r for r in self.people.values() if wanted in full_names[r.entity_id]]:
447 return exact
448 return [r for r in self.people.values() if wanted in {n.split(" ")[0] for n in full_names[r.entity_id]}]
450 def person_id_for_name(self, name: str) -> str | None:
451 """The one recipient called this, by full name, alias or unique first name"""
452 named: list[Recipient] = self.people_named(name)
453 return named[0].entity_id if len(named) == 1 else None
455 def person_id_for_user_id(self, user_id: str | None) -> str | None:
456 for recipient in self.people.values():
457 if user_id and recipient.user_id == user_id:
458 return recipient.entity_id
459 return None
461 def find_people(self) -> list[str]:
462 return self.hass_api.entity_ids_for_domain(PERSON_DOMAIN)
464 def notify_entities(self) -> dict[str, Recipient]:
465 return {p.notify_entity_id: p for p in self.people.values() if p.notify_entity_id}
467 def enabled_recipients(self) -> list[Recipient]:
468 return [p for p in self.people.values() if p.enabled]
470 def filter_recipients_by_occupancy(self, delivery_occupancy: str) -> list[Recipient]:
471 if delivery_occupancy == OCCUPANCY_NONE:
472 return []
474 people = [p for p in self.people.values() if p.enabled]
475 if delivery_occupancy == OCCUPANCY_ALL:
476 return people
478 occupancy = self.determine_occupancy()
480 away = occupancy[STATE_NOT_HOME]
481 at_home = occupancy[STATE_HOME]
482 if delivery_occupancy == OCCUPANCY_ALL_IN:
483 return people if len(away) == 0 else []
484 if delivery_occupancy == OCCUPANCY_ALL_OUT:
485 return people if len(at_home) == 0 else []
486 if delivery_occupancy == OCCUPANCY_ANY_IN:
487 return people if len(at_home) > 0 else []
488 if delivery_occupancy == OCCUPANCY_ANY_OUT:
489 return people if len(away) > 0 else []
490 if delivery_occupancy == OCCUPANCY_ONLY_IN:
491 return at_home
492 if delivery_occupancy == OCCUPANCY_ONLY_OUT:
493 return away
495 _LOGGER.warning("SUPERNOTIFY Unknown occupancy tested: %s", delivery_occupancy)
496 return []
498 def _fetch_person_entity_state(self, person_id: str) -> str | None:
499 try:
500 tracker: State | None = self.hass_api.get_state(person_id)
501 if tracker and isinstance(tracker.state, str):
502 return tracker.state
503 _LOGGER.debug("SUPERNOTIFY Unexpected state %s for %s", tracker, person_id)
504 except Exception as e:
505 _LOGGER.warning("SUPERNOTIFY Unable to determine occupied status for %s: %s", person_id, e)
506 return None
508 def determine_occupancy(self) -> dict[str, list[Recipient]]:
509 results: dict[str, list[Recipient]] = {STATE_HOME: [], STATE_NOT_HOME: []}
510 for person_id, person_config in self.people.items():
511 if person_config.enabled and person_config.has_person:
512 state: str | None = self._fetch_person_entity_state(person_id)
513 if state in (None, STATE_HOME):
514 # default to at home if unknown tracker
515 results[STATE_HOME].append(person_config)
516 else:
517 results[STATE_NOT_HOME].append(person_config)
518 return results
520 def mobile_devices_for_person(self, person_entity_id: str) -> list[TrackedDeviceDetails]:
521 """Auto detect mobile_app targets for a person.
523 Targets not currently validated as async registration may not be complete at this stage
525 Args:
526 ----
527 person_entity_id (str): _description_
529 Returns:
530 -------
531 list: mobile target actions for this person
533 """
534 person_state = self.hass_api.get_state(person_entity_id)
535 if not person_state:
536 _LOGGER.warning("SUPERNOTIFY Unable to resolve %s", person_entity_id)
537 else:
538 user_id = person_state.attributes.get(ATTR_USER_ID)
539 if user_id:
540 return self.mobile_devices_for_user(user_id)
541 _LOGGER.debug("SUPERNOTIFY Unable to link %s to a user_id", person_entity_id)
542 return []
544 def mobile_devices_for_user(self, user_id: str) -> list[TrackedDeviceDetails]:
545 """Auto detect mobile_app targets for a HA user, with no Person entity required."""
546 return self.hass_api.mobile_app_by_user_id(user_id) or []