Coverage for custom_components/supernotify/people.py: 95%
337 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +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 SUBENTRY_TYPE_RECIPIENT,
53)
54from .model import DeliveryCustomization, NotifyEntityPlatform, Target
56if TYPE_CHECKING:
57 from collections.abc import Mapping
59 from homeassistant.config_entries import ConfigSubentry
60 from homeassistant.core import Context, State
62 from .binary_sensor import SupernotifyRecipientBinarySensor
63 from .hass_api import HomeAssistantAPI, TrackedDeviceDetails
66_LOGGER = logging.getLogger(__name__)
69class RecipientNotifyEntity(NotifyEntity):
70 """Expose a single recipient as its own `notify.recipient_<name>` entity.
72 Sent as an ordinary `target` on the main supernotify action, just like any other entity -
73 Notification recognizes it as one of supernotify's own published recipient notify entities
74 and resolves it to this recipient, still going through the full default-recipient delivery pipeline
75 (occupancy, scenarios, personal delivery overrides, dedupe, snooze) rather than a literal,
76 unscoped target override. Per HA's notify entity service schema, only message/title are
77 ever passed in here - no data/target.
78 """
80 _attr_has_entity_name = True
81 _attr_translation_key = "recipient"
83 def __init__(
84 self,
85 unique_id: str,
86 recipient: Recipient,
87 engine: NotifyEntityPlatform,
88 ) -> None:
89 """Initialize the recipient notify entity."""
90 self._attr_unique_id = unique_id
91 self._attr_name = recipient.alias or recipient.name
92 self._attr_supported_features = NotifyEntityFeature.TITLE
93 self._recipient = recipient
94 self._engine = engine
95 self.entity_id = f"notify.recipient_{recipient.name}"
96 # Fallback attribute for HA < 2026.3 (python_full_version < '3.14.2' in pyproject.toml
97 # pins homeassistant==2026.2.3) - NotifyEntity there has no _async_record_notification()
98 # and no other supported way to set its native `state` from outside a direct
99 # notify.recipient_<name> service call (the logic lives in the @final, HA-framework-only
100 # _async_send_message()). Left unused - and extra_state_attributes returns None - once
101 # running against a HA version that has _async_record_notification(); see
102 # record_notification() below for the runtime hasattr() check that picks a path. Support
103 # for the pre-2026.3 lane is time-limited: see the "python_313_deprecated" repair issue,
104 # dropped when HA 2026.10 ships - this whole fallback (and the property below) can go
105 # once that lane is gone.
106 self._last_notified: str | None = None
108 @property
109 def extra_state_attributes(self) -> dict[str, Any] | None:
110 """Only populated on the pre-2026.3 fallback path - see record_notification()."""
111 if hasattr(self, "_async_record_notification"):
112 return None
113 return {"last_notified": self._last_notified}
115 def record_notification(self, context: Context | None = None) -> None:
116 """Record that this recipient was notified via supernotify.notify's main pipeline
117 (any target - person_id, email, mobile device...), not just via a direct call to
118 this notify.recipient_<name> entity. Called from Notification.record_result().
120 Delegates to NotifyEntity._async_record_notification() when available - the same
121 HA-native method html5/mobile_app call on a direct notify.recipient_<name> service
122 call - so `state` reflects the most recent delivery via either path. On HA versions
123 before 2026.3 that method doesn't exist (see the docstring on self._last_notified in
124 __init__), so this falls back to the pre-refactor design: a separate extra_state_attributes
125 attribute, restored manually in async_added_to_hass() below. Either way, a message routed
126 through supernotify.notify's main pipeline (the common case) never invokes this entity's
127 own async_send_message() - see convert_notify_entities() in notification.py, which
128 short-circuits that target straight to a person_id to avoid calling back into this same
129 entity in a loop - so without this explicit call, delivery via that pipeline would never
130 be reflected here at all.
132 `context` is the calling HA service context, if any, so the state change is attributed to
133 the action call (or automation, or user) that caused it, in the logbook and history, just
134 as it would be for a direct notify.recipient_<name> service call."""
135 if context is not None:
136 self.async_set_context(context)
137 if hasattr(self, "_async_record_notification"):
138 self._async_record_notification()
139 else:
140 self._last_notified = dt_util.utcnow().isoformat()
141 self.async_write_ha_state()
143 async def async_added_to_hass(self) -> None:
144 await super().async_added_to_hass()
145 self._recipient.notify_entity_id = self.entity_id
146 self._recipient.notify_entity = self
147 # Restoring `state` after a restart is handled for us by
148 # NotifyEntity.async_internal_added_to_hass() when _async_record_notification() is
149 # available. On the pre-2026.3 fallback path there's no such restore, so do it manually
150 # here, same as before the refactor.
151 if not hasattr(self, "_async_record_notification"):
152 last_state = await self.async_get_last_state()
153 if last_state is not None:
154 self._last_notified = last_state.attributes.get("last_notified")
156 async def async_will_remove_from_hass(self) -> None:
157 self._recipient.notify_entity_id = None
158 self._recipient.notify_entity = None
159 await super().async_will_remove_from_hass()
161 async def async_send_message(self, message: str, title: str | None = None) -> None:
162 """Send a message to this recipient."""
163 await self._engine.async_send_message(
164 message, title=title, target=self.entity_id, context=self._context, via_notify=self.entity_id
165 )
168def recipient_key(config: dict[str, Any]) -> str | None:
169 """What identifies a recipient's configuration - its person, else its user, else for
170 someone who isn't a Home Assistant user, the synthetic `user.` id its alias gives it"""
171 if person := config.get(CONF_PERSON):
172 return str(person)
173 if user_id := config.get(CONF_USER_ID):
174 return str(user_id)
175 if alias := config.get(CONF_ALIAS):
176 return f"user.{slugify(alias)}"
177 return None
180class Recipient:
181 """Recipient to distinguish from the native HA Person.
183 The "future native entity use" this class was once staged for (BinarySensorDeviceClass,
184 EntityCategory, etc.) has arrived as SupernotifyRecipientBinarySensor in binary_sensor.py -
185 a wrapper Entity holding a reference to a Recipient, the same composition already used for
186 RecipientNotifyEntity above, rather than this plain domain object inheriting from Entity.
187 """
189 def __init__(self, config: dict[str, Any] | None, default_mobile_discovery: bool = True) -> None:
190 config = config or {}
191 self.alias: str | None = config.get(CONF_ALIAS)
192 self.email: str | None = config.get(CONF_EMAIL)
193 self.phone_number: str | None = config.get(CONF_PHONE_NUMBER)
194 self.user_id: str | None = config.get(CONF_USER_ID)
195 # A recipient normally is a native HA Person, but CONF_PERSON is optional - a household
196 # that only sets up Users (no Person records) still has a working, addressable recipient
197 # via CONF_USER_ID alone. entity_id is then a synthetic, never-registered id (`user.<name>`
198 # rather than `person.<name>` - both kinds of config are still "recipients", so that word
199 # alone wouldn't distinguish them), used purely as this recipient's internal key (person_id
200 # target category, notify/binary_sensor/switch entity naming, snooze/delivery-override
201 # lookups) - see also the "impossible for a real HA entity to exist" cases these lookups
202 # already handle gracefully, e.g. PeopleRegistry person_attributes()/
203 # _fetch_person_entity_state() returning None for an unknown entity_id.
204 self.entity_id: str = config.get(CONF_PERSON) or ""
205 # what identifies its configuration, and so its subentry, see recipient_key()
206 self.key: str | None = recipient_key(config)
207 # Without a Person there's no presence, so the recipient is left out of occupancy
208 self.has_person: bool = bool(self.entity_id)
209 if self.entity_id:
210 self.name: str = self.entity_id.replace("person.", "")
211 else:
212 self.name = slugify(self.alias or self.user_id or "")
213 self.entity_id = f"user.{self.name}"
214 self.notify_entity_id: str | None = None
215 # Set/cleared by RecipientNotifyEntity.async_added_to_hass()/async_will_remove_from_hass()
216 # - the live entity object itself, so record_notification() can be called directly from
217 # Notification.record_result() without a registry lookup by entity_id.
218 self.notify_entity: RecipientNotifyEntity | None = None
220 self._target: Target = Target(config.get(CONF_TARGET, {}), target_data=config.get(CONF_DATA))
221 self.delivery_overrides: dict[str, DeliveryCustomization] = {
222 k: DeliveryCustomization(config=v, target_specific=True) for k, v in config.get(CONF_DELIVERY, {}).items()
223 }
224 self.enabled: bool = config.get(CONF_ENABLED, True)
225 # as configured, which enabled can be overridden from at runtime by the recipient switch
226 self.config_enabled: bool = self.enabled
227 self.mobile_discovery: bool = config.get(CONF_MOBILE_DISCOVERY, default_mobile_discovery)
228 self.mobile_devices: dict[str, dict[str, str | list[str] | None]] = {
229 c[CONF_MOBILE_APP_ID]: c for c in config.get(CONF_MOBILE_DEVICES, [])
230 }
231 self.disabled_mobile_app_ids: list[str] = [k for k, v in self.mobile_devices.items() if not v.get(CONF_ENABLED, True)]
232 # as configured, to start again from each time the discovered devices are refreshed
233 self._configured_mobile_devices: dict[str, dict[str, str | list[str] | None]] = {
234 k: dict(v) for k, v in self.mobile_devices.items()
235 }
236 self._mobile_app_issues: set[str] = set()
237 _LOGGER.debug("SUPERNOTIFY Recipient config %s -> %s", config, self.as_dict(redact=True))
239 def initialize(self, people_registry: PeopleRegistry) -> None:
241 self._target.extend(ATTR_PERSON_ID, [self.entity_id])
242 if self.email:
243 self._target.extend(ATTR_EMAIL, self.email)
244 if self.phone_number:
245 self._target.extend(ATTR_PHONE, self.phone_number)
246 self.resolve_mobile_devices(people_registry)
247 if not self.user_id or not self.alias:
248 attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id)
249 if attrs:
250 if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str):
251 self.user_id = attrs.get(ATTR_USER_ID)
252 if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str):
253 self.alias = attrs.get(ATTR_ALIAS)
254 if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str):
255 self.alias = attrs.get(ATTR_FRIENDLY_NAME)
256 _LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id)
257 else:
258 _LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id)
259 _LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True))
261 def on_notification(self, context: Context | None = None) -> None:
262 # Record that a notification has occurred for this person
263 if self.notify_entity is not None:
264 self.notify_entity.record_notification(context)
266 def resolve_mobile_devices(self, people_registry: PeopleRegistry) -> None:
267 """The configured mobile devices plus those discovered now - again whenever one is re-paired or renamed"""
268 previous: list[str] = list(self.mobile_devices)
269 self.mobile_devices = {k: dict(v) for k, v in self._configured_mobile_devices.items()}
270 # someone who isn't a Home Assistant user has no mobile app devices to discover
271 if self.mobile_discovery and (self.user_id or self.has_person):
272 # a known user_id (explicitly configured, or already backfilled below from a prior
273 # initialize()) resolves devices directly, without needing a Person entity at all
274 discovered_devices: list[TrackedDeviceDetails] = (
275 people_registry.mobile_devices_for_user(self.user_id)
276 if self.user_id
277 else people_registry.mobile_devices_for_person(self.entity_id)
278 )
279 if discovered_devices:
280 new_ids = []
281 for d in discovered_devices:
282 if d.mobile_app_id in self.mobile_devices:
283 # merge with manual registrations, with priority to manually overridden values
284 merged = d.as_dict()
285 merged.update(self.mobile_devices[d.mobile_app_id])
286 self.mobile_devices[d.mobile_app_id] = merged
287 new_ids.append(d.mobile_app_id)
288 _LOGGER.debug("SUPERNOTIFY Updating %s mobile device %s from registry", self.entity_id, d.mobile_app_id)
289 elif d.mobile_app_id is not None and d.action:
290 # a mobile app with no notify action can't be notified, and may never be,
291 # like an Apple Watch, which registers with Home Assistant without a push token
292 self.mobile_devices[d.mobile_app_id] = d.as_dict()
293 new_ids.append(d.mobile_app_id)
294 _LOGGER.info(
295 "SUPERNOTIFY Auto configured %s for mobile devices %s",
296 self.entity_id,
297 ",".join(new_ids),
298 )
299 else:
300 _LOGGER.info("SUPERNOTIFY Unable to find mobile devices for %s", self.entity_id)
301 self._target.remove(ATTR_MOBILE_APP_ID, previous)
302 if self.mobile_devices:
303 self._target.extend(ATTR_MOBILE_APP_ID, list(self.enabled_mobile_devices.keys()))
304 self.check_mobile_actions(people_registry.hass_api)
306 def check_mobile_actions(self, hass_api: HomeAssistantAPI) -> None:
307 """Raise a repair for a configured, enabled mobile device with no notify action, and clear it once that's resolved
309 Only for the ones listed under `mobile_devices` - a discovered one has nothing for the user to repair
310 """
311 missing: set[str] = set()
312 for mobile_app_id in self.enabled_mobile_devices:
313 if mobile_app_id not in self._configured_mobile_devices:
314 continue
315 action: str = mobile_app_id.removeprefix("notify.")
316 if not hass_api.mobile_app_action_exists(action):
317 issue_id: str = f"recipient_{self.name}_mobile_app_{action}_not_found"
318 missing.add(issue_id)
319 if issue_id not in self._mobile_app_issues:
320 _LOGGER.warning("SUPERNOTIFY No notify action found for %s mobile device %s", self.entity_id, action)
321 hass_api.raise_issue(
322 issue_id,
323 issue_key="recipient_mobile_app_not_found",
324 issue_map={"recipient": self.entity_id, "mobile_app": action},
325 learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/",
326 )
327 for issue_id in self._mobile_app_issues - missing:
328 hass_api.delete_issue(issue_id)
329 self._mobile_app_issues = missing
331 @property
332 def enabled_mobile_devices(self) -> dict[str, dict[str, str | list[str] | None]]:
333 return {k: v for k, v in self.mobile_devices.items() if v.get(CONF_ENABLED, True)}
335 def enabling_delivery_names(self) -> list[str]:
336 """Explicitly overriding enabled state"""
337 return [
338 delname
339 for delname, delconf in self.delivery_overrides.items()
340 if delconf.enabled is not None and delconf.enabled is True
341 ]
343 def disabling_delivery_names(self) -> list[str]:
344 """Explicitly overriding enabled state"""
345 return [
346 delname
347 for delname, delconf in self.delivery_overrides.items()
348 if delconf.enabled is not None and delconf.enabled is False
349 ]
351 def target(self, delivery_name: str) -> Target:
352 recipient_target: Target = self._target
353 personal_delivery: DeliveryCustomization | None = self.delivery_overrides.get(delivery_name)
354 if personal_delivery and personal_delivery.enabled is not False:
355 if personal_delivery.target and personal_delivery.target.has_targets():
356 recipient_target += personal_delivery.target
357 if personal_delivery.data:
358 recipient_target += Target([], target_data=personal_delivery.data, target_specific_data=True)
359 return recipient_target
361 def as_dict(self, occupancy_only: bool = False, redact: bool = False, **_kwargs: Any) -> dict[str, Any]:
362 result = {CONF_PERSON: self.entity_id, CONF_ENABLED: self.enabled}
363 if not occupancy_only:
364 result.update({
365 CONF_ALIAS: self.alias,
366 CONF_EMAIL: self.email,
367 CONF_PHONE_NUMBER: self.phone_number,
368 ATTR_USER_ID: self.user_id,
369 CONF_MOBILE_DISCOVERY: self.mobile_discovery,
370 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()),
371 CONF_TARGET: self._target.as_dict() if self._target else None,
372 CONF_DELIVERY: {d: c.as_dict() for d, c in self.delivery_overrides.items()}
373 if self.delivery_overrides
374 else None,
375 })
376 if redact:
377 for k in (CONF_EMAIL, CONF_PHONE_NUMBER):
378 if k in result:
379 result[k] = partial_redact(result[k], unmasked_prefix=2, unmasked_suffix=1)
380 return result
382 def attributes(self) -> dict[str, Any]:
383 """For exposure as entity state"""
384 attrs: dict[str, Any] = {
385 ATTR_ENTITY_ID: self.entity_id,
386 ATTR_ENABLED: self.enabled,
387 CONF_EMAIL: self.email,
388 CONF_PHONE_NUMBER: self.phone_number,
389 ATTR_USER_ID: self.user_id,
390 CONF_MOBILE_DEVICES: list(self.mobile_devices.values()),
391 CONF_MOBILE_DISCOVERY: self.mobile_discovery,
392 CONF_TARGET: self._target,
393 CONF_DELIVERY: self.delivery_overrides,
394 }
395 if self.alias:
396 attrs[ATTR_FRIENDLY_NAME] = self.alias
397 return attrs
400class PeopleRegistry:
401 def __init__(
402 self,
403 recipients: list[dict[str, Any]],
404 hass_api: HomeAssistantAPI,
405 discover: bool = False,
406 mobile_discovery: bool = True,
407 ) -> None:
408 self.hass_api = hass_api
409 self.people: dict[str, Recipient] = {}
410 self._recipients: list[dict[str, Any]] = ensure_list(recipients)
411 self.mobile_discovery = mobile_discovery
412 self.discover = discover
413 # the configuration of each recipient discovered, by recipient_key(), before any configured
414 # recipient is merged over it
415 self.discovered: dict[str, dict[str, Any]] = {}
416 # Populated by binary_sensor.py's async_setup_entry once the platform is loaded - see
417 # register_entity/unregister_entity. Empty (and harmless to look up against) before
418 # then, and in tests that build PeopleRegistry directly without a config entry.
419 self._entities: dict[str, SupernotifyRecipientBinarySensor] = {}
421 async def initialize(self) -> None:
422 recipients: dict[str, dict[str, Any]] = {}
423 if self.discover:
424 entity_ids = self.find_people()
425 person_user_ids: set[str] = set()
426 if entity_ids:
427 recipients = {entity_id: {CONF_PERSON: entity_id} for entity_id in entity_ids}
428 for entity_id in entity_ids:
429 attrs = self.person_attributes(entity_id)
430 if attrs and isinstance(attrs.get(ATTR_USER_ID), str):
431 person_user_ids.add(attrs[ATTR_USER_ID])
432 _LOGGER.info("SUPERNOTIFY Auto-discovered people: %s", entity_ids)
434 # Users are the essential HA concept - mobile_app itself only requires one, a Person
435 # is a separate, optional layer (see CONF_USER_ID in const.py) - so a real user with
436 # no matching Person here is still discoverable, just without Person's presence
437 # tracking/UI. A user already represented by a Person above is not duplicated.
438 real_user_ids = await self.hass_api.async_real_user_ids()
439 user_only_ids = {uid: name for uid, name in real_user_ids.items() if uid not in person_user_ids}
440 if user_only_ids:
441 recipients.update({uid: {CONF_USER_ID: uid, CONF_ALIAS: name} for uid, name in user_only_ids.items()})
442 _LOGGER.info("SUPERNOTIFY Auto-discovered user-only accounts: %s", list(user_only_ids.values()))
443 self.discovered = {k: dict(v) for k, v in recipients.items()}
445 for r in self._recipients:
446 # merge/dedupe key only - the recipient's real identity (entity_id) is derived by
447 # Recipient itself, below, from whichever of these was given
448 key = recipient_key(r)
449 if not key:
450 _LOGGER.warning("SUPERNOTIFY Skipping invalid recipient with no 'person', 'user_id' or 'alias':%s", r)
451 continue
452 if key in recipients:
453 _LOGGER.debug("SUPERNOTIFY Overriding %s entity defaults from recipient config", key)
454 recipients[key].update(r)
455 else:
456 recipients[key] = r
458 for r in recipients.values():
459 recipient: Recipient = Recipient(r, default_mobile_discovery=self.mobile_discovery)
460 recipient.initialize(self)
462 self.people[recipient.entity_id] = recipient
464 def refresh_mobile_devices(self) -> None:
465 """Rediscover every recipient's mobile devices, after one was added, removed, re-paired or renamed"""
466 self.hass_api.build_mobile_app_cache()
467 for recipient in self.people.values():
468 recipient.resolve_mobile_devices(self)
469 self.async_refresh_entity(recipient.name)
471 def register_entity(self, name: str, entity: SupernotifyRecipientBinarySensor) -> None:
472 """Called by SupernotifyRecipientBinarySensor.async_added_to_hass()."""
473 self._entities[name] = entity
475 def unregister_entity(self, name: str) -> None:
476 """Called by SupernotifyRecipientBinarySensor.async_will_remove_from_hass()."""
477 self._entities.pop(name, None)
479 def recipient_entities(self) -> list[SupernotifyRecipientBinarySensor]:
480 """Every registered recipient binary_sensor - used by supernotify.refresh_entities."""
481 return list(self._entities.values())
483 @callback
484 def async_refresh_entity(self, name: str) -> None:
485 """Re-publish one recipient's binary_sensor now"""
486 entity = self._entities.get(name)
487 if entity is not None:
488 entity.async_write_ha_state()
490 def expose_notify_entities(
491 self,
492 entry_id: str,
493 async_add_entities: AddConfigEntryEntitiesCallback,
494 service: NotifyEntityPlatform,
495 subentry_ids: dict[str, str],
496 ) -> None:
497 """A notify entity for each recipient, in its subentry where it has one, with no device, so
498 a recipient is only listed once in the integration, as its subentry"""
499 for recipient in self.people.values():
500 entity = RecipientNotifyEntity(f"{entry_id}_recipient_{recipient.name}", recipient, service)
501 async_add_entities([entity], config_subentry_id=subentry_ids.get(recipient.key or ""))
503 def subentry_ids(self, subentries: Mapping[str, ConfigSubentry]) -> dict[str, str]:
504 """The id of each recipient subentry, by recipient_key() of its configuration"""
505 return {
506 key: subentry.subentry_id
507 for subentry in subentries.values()
508 if subentry.subentry_type == SUBENTRY_TYPE_RECIPIENT and (key := recipient_key(dict(subentry.data)))
509 }
511 def person_attributes(self, entity_id: str) -> dict[str, Any] | None:
512 state: State | None = self.hass_api.get_state(entity_id)
513 if state is not None and state.attributes:
514 return state.attributes
515 return None
517 def name_for_user_id(self, user_id: str) -> str | None:
518 for recipient in self.people.values():
519 if recipient.user_id == user_id:
520 return recipient.alias or recipient.name
521 return None
523 def people_named(self, name: str) -> list[Recipient]:
524 """The recipients called this, by alias or name, ignoring case and underscores - or failing
525 that, those with it as their first name, so 'jey' finds 'Jey Burrows'"""
526 wanted: str = name.replace("_", " ").casefold().strip()
527 full_names: dict[str, set[str]] = {
528 recipient.entity_id: {n.replace("_", " ").casefold().strip() for n in (recipient.alias, recipient.name) if n}
529 for recipient in self.people.values()
530 }
531 if exact := [r for r in self.people.values() if wanted in full_names[r.entity_id]]:
532 return exact
533 return [r for r in self.people.values() if wanted in {n.split(" ")[0] for n in full_names[r.entity_id]}]
535 def person_id_for_name(self, name: str) -> str | None:
536 """The one recipient called this, by full name, alias or unique first name"""
537 named: list[Recipient] = self.people_named(name)
538 return named[0].entity_id if len(named) == 1 else None
540 def person_id_for_user_id(self, user_id: str | None) -> str | None:
541 for recipient in self.people.values():
542 if user_id and recipient.user_id == user_id:
543 return recipient.entity_id
544 return None
546 def find_people(self) -> list[str]:
547 return self.hass_api.entity_ids_for_domain(PERSON_DOMAIN)
549 def notify_entities(self) -> dict[str, Recipient]:
550 return {p.notify_entity_id: p for p in self.people.values() if p.notify_entity_id}
552 def enabled_recipients(self) -> list[Recipient]:
553 return [p for p in self.people.values() if p.enabled]
555 def occupancy_permits_delivery(self, delivery_occupancy: str, occupancy: dict[str, list[Recipient]]) -> bool:
556 """Whether a delivery should be attempted at all under the given occupancy, as opposed
557 to filter_recipients_by_occupancy() below, which narrows *which* recipients within an
558 already-selected delivery get targeted. `only_in`/`only_out` collapse to `any_in`/
559 `any_out` here: they narrow which recipients to target, but don't change whether the
560 delivery itself is relevant, which is all a gate can meaningfully decide."""
561 if delivery_occupancy == OCCUPANCY_NONE:
562 return False
563 if delivery_occupancy == OCCUPANCY_ALL:
564 return True
565 away = len(occupancy[STATE_NOT_HOME])
566 at_home = len(occupancy[STATE_HOME])
567 if delivery_occupancy == OCCUPANCY_ALL_IN:
568 return away == 0
569 if delivery_occupancy == OCCUPANCY_ALL_OUT:
570 return at_home == 0
571 if delivery_occupancy in (OCCUPANCY_ANY_IN, OCCUPANCY_ONLY_IN):
572 return at_home > 0
573 if delivery_occupancy in (OCCUPANCY_ANY_OUT, OCCUPANCY_ONLY_OUT):
574 return away > 0
575 _LOGGER.warning("SUPERNOTIFY Unknown occupancy tested: %s", delivery_occupancy)
576 return True
578 def filter_recipients_by_occupancy(self, delivery_occupancy: str) -> list[Recipient]:
579 if delivery_occupancy == OCCUPANCY_NONE:
580 return []
582 people = [p for p in self.people.values() if p.enabled]
583 if delivery_occupancy == OCCUPANCY_ALL:
584 return people
586 occupancy = self.determine_occupancy()
588 away = occupancy[STATE_NOT_HOME]
589 at_home = occupancy[STATE_HOME]
590 if delivery_occupancy == OCCUPANCY_ALL_IN:
591 return people if len(away) == 0 else []
592 if delivery_occupancy == OCCUPANCY_ALL_OUT:
593 return people if len(at_home) == 0 else []
594 if delivery_occupancy == OCCUPANCY_ANY_IN:
595 return people if len(at_home) > 0 else []
596 if delivery_occupancy == OCCUPANCY_ANY_OUT:
597 return people if len(away) > 0 else []
598 if delivery_occupancy == OCCUPANCY_ONLY_IN:
599 return at_home
600 if delivery_occupancy == OCCUPANCY_ONLY_OUT:
601 return away
603 _LOGGER.warning("SUPERNOTIFY Unknown occupancy tested: %s", delivery_occupancy)
604 return []
606 def _fetch_person_entity_state(self, person_id: str) -> str | None:
607 try:
608 tracker: State | None = self.hass_api.get_state(person_id)
609 if tracker and isinstance(tracker.state, str):
610 return tracker.state
611 _LOGGER.debug("SUPERNOTIFY Unexpected state %s for %s", tracker, person_id)
612 except Exception as e:
613 _LOGGER.warning("SUPERNOTIFY Unable to determine occupied status for %s: %s", person_id, e)
614 return None
616 def determine_occupancy(self) -> dict[str, list[Recipient]]:
617 results: dict[str, list[Recipient]] = {STATE_HOME: [], STATE_NOT_HOME: []}
618 for person_id, person_config in self.people.items():
619 if person_config.enabled and person_config.has_person:
620 state: str | None = self._fetch_person_entity_state(person_id)
621 if state in (None, STATE_HOME):
622 # default to at home if unknown tracker
623 results[STATE_HOME].append(person_config)
624 else:
625 results[STATE_NOT_HOME].append(person_config)
626 return results
628 def mobile_devices_for_person(self, person_entity_id: str) -> list[TrackedDeviceDetails]:
629 """Auto detect mobile_app targets for a person.
631 Targets not currently validated as async registration may not be complete at this stage
633 Args:
634 ----
635 person_entity_id (str): _description_
637 Returns:
638 -------
639 list: mobile target actions for this person
641 """
642 person_state = self.hass_api.get_state(person_entity_id)
643 if not person_state:
644 _LOGGER.warning("SUPERNOTIFY Unable to resolve %s", person_entity_id)
645 else:
646 user_id = person_state.attributes.get(ATTR_USER_ID)
647 if user_id:
648 return self.mobile_devices_for_user(user_id)
649 _LOGGER.debug("SUPERNOTIFY Unable to link %s to a user_id", person_entity_id)
650 return []
652 def mobile_devices_for_user(self, user_id: str) -> list[TrackedDeviceDetails]:
653 """Auto detect mobile_app targets for a HA user, with no Person entity required."""
654 return self.hass_api.mobile_app_by_user_id(user_id) or []