Coverage for custom_components/supernotify/notify.py: 91%
352 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +0000
1"""Supernotify service, extending BaseNotificationService"""
3from __future__ import annotations
5import json
6import logging
7from dataclasses import asdict
8from traceback import format_exception
9from typing import TYPE_CHECKING, Any, Final
11from homeassistant.components.notify import (
12 NotifyEntity,
13 NotifyEntityFeature,
14)
15from homeassistant.components.notify.legacy import BaseNotificationService
16from homeassistant.const import (
17 EVENT_HOMEASSISTANT_STOP,
18 STATE_OFF,
19 STATE_ON,
20 STATE_UNKNOWN,
21 EntityCategory,
22 Platform,
23)
24from homeassistant.core import (
25 Event,
26 EventStateChangedData,
27 HomeAssistant,
28 ServiceCall,
29 State,
30 SupportsResponse,
31 callback,
32)
33from homeassistant.exceptions import ServiceValidationError
34from homeassistant.helpers.json import ExtendedJSONEncoder
36from . import DOMAIN
37from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive
38from .common import DupeChecker, sanitize
39from .const import (
40 ATTR_ACTION,
41 ATTR_DATA,
42 CONF_ACTION_GROUPS,
43 CONF_ACTIONS,
44 CONF_ARCHIVE,
45 CONF_CAMERAS,
46 CONF_DELIVERY,
47 CONF_DUPE_CHECK,
48 CONF_HOUSEKEEPING,
49 CONF_HOUSEKEEPING_TIME,
50 CONF_LINKS,
51 CONF_MEDIA_PATH,
52 CONF_MEDIA_STORAGE_DAYS,
53 CONF_MEDIA_URL_PREFIX,
54 CONF_MOBILE_DISCOVERY,
55 CONF_RECIPIENTS,
56 CONF_RECIPIENTS_DISCOVERY,
57 CONF_SCENARIOS,
58 CONF_SNOOZE,
59 CONF_TEMPLATE_PATH,
60 CONF_TRANSPORTS,
61 PRIORITY_MEDIUM,
62)
63from .context import Context
64from .delivery import DeliveryRegistry
65from .hass_api import HomeAssistantAPI
66from .media_grab import MediaStorage
67from .model import ConditionVariables, SuppressionReason
68from .notification import Notification
69from .people import PeopleRegistry, Recipient
70from .scenario import ScenarioRegistry
71from .snoozer import Snoozer
72from .transports.alexa_devices import AlexaDevicesTransport
73from .transports.alexa_media_player import AlexaMediaPlayerTransport
74from .transports.chime import ChimeTransport
75from .transports.discord import DiscordTransport
76from .transports.email import EmailTransport
77from .transports.generic import GenericTransport
78from .transports.gotify import GotifyTransport
79from .transports.html5 import HTML5Transport
80from .transports.kodi import KodiTransport
81from .transports.lametric import LaMetricTransport
82from .transports.matrix import MatrixTransport
83from .transports.media_player import MediaPlayerTransport
84from .transports.mobile_push import MobilePushTransport
85from .transports.mqtt import MQTTTransport
86from .transports.notify_entity import NotifyEntityTransport
87from .transports.ntfy import NtfyTransport
88from .transports.persistent import PersistentTransport
89from .transports.pushover import PushoverTransport
90from .transports.sms import SMSTransport
91from .transports.telegram import TelegramTransport
92from .transports.tts import TTSTransport
94if TYPE_CHECKING:
95 import datetime as dt
97 from homeassistant.helpers import entity_registry as er
98 from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
99 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
101 from . import SupernotifyConfigEntry
102 from .scenario import Scenario
103 from .transport import Transport
105PARALLEL_UPDATES = 0
107_LOGGER = logging.getLogger(__name__)
109TRANSPORTS: list[type[Transport]] = [
110 EmailTransport,
111 SMSTransport,
112 MQTTTransport,
113 AlexaDevicesTransport,
114 AlexaMediaPlayerTransport,
115 MobilePushTransport,
116 MediaPlayerTransport,
117 ChimeTransport,
118 PersistentTransport,
119 GenericTransport,
120 TTSTransport,
121 NotifyEntityTransport,
122 NtfyTransport,
123 GotifyTransport,
124 TelegramTransport,
125 LaMetricTransport,
126 PushoverTransport,
127 HTML5Transport,
128 MatrixTransport,
129 KodiTransport,
130 DiscordTransport,
131] # No auto-discovery of transport plugins so manual class registration required here
134def build_supernotify_action(hass: HomeAssistant, config: ConfigType) -> SupernotifyAction:
135 """Construct a SupernotifyAction from a fully validated FULL_CONFIG_SCHEMA config dict.
137 Used by the config-entry setup (async_setup_entry in __init__.py), the sole owner of
138 registering notify.supernotify.
139 """
140 return SupernotifyAction(
141 hass,
142 deliveries=config[CONF_DELIVERY],
143 template_path=config[CONF_TEMPLATE_PATH],
144 media_path=config[CONF_MEDIA_PATH],
145 media_url_prefix=config.get(CONF_MEDIA_URL_PREFIX),
146 archive=config[CONF_ARCHIVE],
147 housekeeping=config[CONF_HOUSEKEEPING],
148 mobile_discovery=config[CONF_MOBILE_DISCOVERY],
149 recipients_discovery=config[CONF_RECIPIENTS_DISCOVERY],
150 recipients=config[CONF_RECIPIENTS],
151 mobile_actions=config[CONF_ACTION_GROUPS],
152 scenarios=config[CONF_SCENARIOS],
153 links=config[CONF_LINKS],
154 transport_configs=config[CONF_TRANSPORTS],
155 cameras=config[CONF_CAMERAS],
156 dupe_check=config[CONF_DUPE_CHECK],
157 snooze=config[CONF_SNOOZE],
158 )
161SUPPLEMENTAL_SERVICE_NAMES: Final[tuple[str, ...]] = (
162 "enquire_configuration",
163 "enquire_implicit_deliveries",
164 "enquire_deliveries_by_scenario",
165 "enquire_last_notification",
166 "enquire_active_scenarios",
167 "enquire_scenarios",
168 "enquire_occupancy",
169 "enquire_recipients",
170 "enquire_snoozes",
171 "clear_snoozes",
172 "purge_archive",
173 "purge_media",
174 "refresh_entities",
175)
178@callback
179def async_register_supplemental_services(hass: HomeAssistant, service: SupernotifyAction, config: ConfigType) -> None:
180 """Register the domain-scoped supplemental/debugging/admin services.
182 Shared by the legacy YAML platform (async_get_service, below) and the config-entry setup
183 (async_setup_entry in __init__.py), so both setup paths expose the same services. These
184 are DOMAIN-scoped, not per config entry, so registration is guarded against being run
185 twice - see SUPPLEMENTAL_SERVICE_NAMES/async_unregister_supplemental_services for the
186 matching teardown.
188 enquire_configuration closes over the raw config dict rather than `service`, because
189 several of the fields it reports (delivery/transport/archive/dupe_check config, the full
190 set of configured scenarios/recipients) are either private on the registries after
191 initialize() or lossily reduced to derived values there - so `service` alone can't
192 reconstruct them.
193 """
194 if hass.services.has_service(DOMAIN, "enquire_configuration"):
195 return
197 def supplemental_action_enquire_configuration(_call: ServiceCall) -> dict[str, Any]:
198 return {
199 CONF_DELIVERY: config.get(CONF_DELIVERY, {}),
200 CONF_LINKS: config.get(CONF_LINKS, ()),
201 CONF_TEMPLATE_PATH: config.get(CONF_TEMPLATE_PATH, None),
202 CONF_MEDIA_PATH: config.get(CONF_MEDIA_PATH, None),
203 CONF_ARCHIVE: config.get(CONF_ARCHIVE, {}),
204 CONF_MOBILE_DISCOVERY: config.get(CONF_MOBILE_DISCOVERY, ()),
205 CONF_RECIPIENTS_DISCOVERY: config.get(CONF_RECIPIENTS_DISCOVERY, ()),
206 CONF_RECIPIENTS: config.get(CONF_RECIPIENTS, ()),
207 CONF_ACTIONS: config.get(CONF_ACTIONS, {}),
208 CONF_HOUSEKEEPING: config.get(CONF_HOUSEKEEPING, {}),
209 CONF_ACTION_GROUPS: config.get(CONF_ACTION_GROUPS, {}),
210 CONF_SCENARIOS: list(config.get(CONF_SCENARIOS, {}).keys()),
211 CONF_TRANSPORTS: config.get(CONF_TRANSPORTS, {}),
212 CONF_CAMERAS: config.get(CONF_CAMERAS, {}),
213 CONF_DUPE_CHECK: config.get(CONF_DUPE_CHECK, {}),
214 CONF_SNOOZE: config.get(CONF_SNOOZE, {}),
215 }
217 def supplemental_action_refresh_entities(_call: ServiceCall) -> None:
218 return service.expose_entities()
220 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]:
221 return service.enquire_implicit_deliveries()
223 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]:
224 return service.enquire_deliveries_by_scenario()
226 def supplemental_action_enquire_last_notification(_call: ServiceCall) -> dict[str, Any]:
227 return service.last_notification.contents() if service.last_notification else {}
229 async def supplemental_action_enquire_active_scenarios(call: ServiceCall) -> dict[str, Any]:
230 trace = call.data.get("trace", False)
231 result: dict[str, Any] = {"scenarios": await service.enquire_active_scenarios()}
232 if trace:
233 result["trace"] = await service.trace_active_scenarios()
234 return result
236 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]:
237 return {"scenarios": service.enquire_scenarios()}
239 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]:
240 return {"scenarios": await service.enquire_occupancy()}
242 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]:
243 return {"snoozes": service.enquire_snoozes()}
245 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]:
246 return {"cleared": service.clear_snoozes()}
248 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]:
249 return {"recipients": service.enquire_recipients()}
251 async def supplemental_action_purge_archive(call: ServiceCall) -> dict[str, Any]:
252 days = call.data.get("days")
253 if not service.context.archive.enabled:
254 raise ServiceValidationError("No archive configured")
255 purged = await service.context.archive.cleanup(days=days, force=True)
256 arch_size = await service.context.archive.size()
257 return {
258 "purged": purged,
259 "remaining": arch_size,
260 "interval": ARCHIVE_PURGE_MIN_INTERVAL,
261 "days": service.context.archive.archive_days if days is None else days,
262 }
264 async def supplemental_action_purge_media(call: ServiceCall) -> dict[str, Any]:
265 days = call.data.get("days")
266 if not service.context.media_storage.media_path:
267 raise ServiceValidationError("No media storage configured")
268 purged = await service.context.media_storage.cleanup(days=days, force=True)
269 size = await service.context.media_storage.size()
270 return {
271 "purged": purged,
272 "remaining": size,
273 "interval": service.context.media_storage.purge_minute_interval,
274 "days": service.context.media_storage.days if days is None else days,
275 }
277 hass.services.async_register(
278 DOMAIN,
279 "enquire_configuration",
280 supplemental_action_enquire_configuration,
281 supports_response=SupportsResponse.ONLY,
282 )
283 hass.services.async_register(
284 DOMAIN,
285 "enquire_implicit_deliveries",
286 supplemental_action_enquire_implicit_deliveries,
287 supports_response=SupportsResponse.ONLY,
288 )
289 hass.services.async_register(
290 DOMAIN,
291 "enquire_deliveries_by_scenario",
292 supplemental_action_enquire_deliveries_by_scenario,
293 supports_response=SupportsResponse.ONLY,
294 )
295 hass.services.async_register(
296 DOMAIN,
297 "enquire_last_notification",
298 supplemental_action_enquire_last_notification,
299 supports_response=SupportsResponse.ONLY,
300 )
301 hass.services.async_register(
302 DOMAIN,
303 "enquire_active_scenarios",
304 supplemental_action_enquire_active_scenarios,
305 supports_response=SupportsResponse.ONLY,
306 )
307 hass.services.async_register(
308 DOMAIN,
309 "enquire_scenarios",
310 supplemental_action_enquire_scenarios,
311 supports_response=SupportsResponse.ONLY,
312 )
313 hass.services.async_register(
314 DOMAIN,
315 "enquire_occupancy",
316 supplemental_action_enquire_occupancy,
317 supports_response=SupportsResponse.ONLY,
318 )
319 hass.services.async_register(
320 DOMAIN,
321 "enquire_recipients",
322 supplemental_action_enquire_recipients,
323 supports_response=SupportsResponse.ONLY,
324 )
325 hass.services.async_register(
326 DOMAIN,
327 "enquire_snoozes",
328 supplemental_action_enquire_snoozes,
329 supports_response=SupportsResponse.ONLY,
330 )
331 hass.services.async_register(
332 DOMAIN,
333 "clear_snoozes",
334 supplemental_action_clear_snoozes,
335 supports_response=SupportsResponse.ONLY,
336 )
337 hass.services.async_register(
338 DOMAIN,
339 "purge_archive",
340 supplemental_action_purge_archive,
341 supports_response=SupportsResponse.ONLY,
342 )
343 hass.services.async_register(
344 DOMAIN,
345 "purge_media",
346 supplemental_action_purge_media,
347 supports_response=SupportsResponse.ONLY,
348 )
349 hass.services.async_register(
350 DOMAIN,
351 "refresh_entities",
352 supplemental_action_refresh_entities,
353 supports_response=SupportsResponse.NONE,
354 )
357@callback
358def async_unregister_supplemental_services(hass: HomeAssistant) -> None:
359 """Undo async_register_supplemental_services."""
360 for name in SUPPLEMENTAL_SERVICE_NAMES:
361 if hass.services.has_service(DOMAIN, name):
362 hass.services.async_remove(DOMAIN, name)
365async def async_get_service(
366 hass: HomeAssistant,
367 config: ConfigType,
368 discovery_info: DiscoveryInfoType | None = None,
369) -> SupernotifyAction | None:
370 """Legacy `notify: - platform: supernotify` entrypoint - see async_setup_legacy in legacy
371 BaseNotificationService.
373 The config entry is now the sole, unconditional owner of notify.supernotify (see
374 async_setup_entry in __init__.py), so this leftover legacy YAML block never builds or
375 registers a service any more - it only raises a fixable repair pointing at the migration
376 (see repairs.py) and declines to set up (returning None is HA's supported "decline" path for
377 a legacy notify platform - a clean one-line log, no exception).
379 A `name:` in this leftover block still gets synced onto the owning entry every load though
380 (not gated behind that repair), and likewise for its template_path/media_path/etc and
381 archive/dupe_check/housekeeping settings - otherwise an entry auto-bootstrapped blank by
382 async_setup (see __init__.py), which happens before anyone gets around to opening and
383 confirming the migration repair, would keep running on defaults with nothing configured,
384 silently breaking automations, template/media paths and archiving on every restart until the
385 repair is manually confirmed. That repair is only ever needed for delivery/transports/
386 scenarios/etc - a "simple" install with none of that has no reason to see it at all, so this
387 core migration must not depend on it.
388 """
389 _ = discovery_info
391 from .repairs import async_create_legacy_yaml_issue, async_sync_entry_from_legacy_config
393 legacy_config = dict(config)
394 async_sync_entry_from_legacy_config(hass, legacy_config)
395 async_create_legacy_yaml_issue(hass, legacy_config)
396 return None
399class SupernotifyEntity(NotifyEntity):
400 """Implement supernotify as a NotifyEntity platform."""
402 _attr_has_entity_name = True
403 _attr_name = "supernotify"
405 def __init__(
406 self,
407 unique_id: str,
408 platform: SupernotifyAction,
409 ) -> None:
410 """Initialize the SuperNotify entity."""
411 self._attr_unique_id = unique_id
412 self._attr_supported_features = NotifyEntityFeature.TITLE
413 self._platform = platform
415 async def async_send_message(
416 self, message: str, title: str | None = None, target: str | list[str] | None = None, data: dict[str, Any] | None = None
417 ) -> None:
418 """Send a message to a user."""
419 await self._platform.async_send_message(message, title=title, target=target, data=data)
422class RecipientNotifyEntity(NotifyEntity):
423 """Expose a single recipient as its own `notify.recipient_<name>` entity.
425 Sent as an ordinary `target` on the main supernotify action, just like any other entity -
426 Notification recognizes it as one of supernotify's own published recipient notify entities
427 and resolves it to this recipient, still going through the full default-recipient delivery pipeline
428 (occupancy, scenarios, personal delivery overrides, dedupe, snooze) rather than a literal,
429 unscoped target override. Per HA's notify entity service schema, only message/title are
430 ever passed in here - no data/target.
431 """
433 _attr_has_entity_name = True
435 def __init__(
436 self,
437 unique_id: str,
438 recipient: Recipient,
439 platform: SupernotifyAction,
440 ) -> None:
441 """Initialize the recipient notify entity."""
442 self._attr_unique_id = unique_id
443 self._attr_name = recipient.alias or recipient.name
444 self._attr_supported_features = NotifyEntityFeature.TITLE
445 self._recipient = recipient
446 self._platform = platform
447 self.entity_id = f"notify.recipient_{recipient.name}"
449 async def async_added_to_hass(self) -> None:
450 await super().async_added_to_hass()
451 self._recipient.notify_entity_id = self.entity_id
453 async def async_will_remove_from_hass(self) -> None:
454 self._recipient.notify_entity_id = None
455 await super().async_will_remove_from_hass()
457 async def async_send_message(self, message: str, title: str | None = None) -> None:
458 """Send a message to this recipient."""
459 await self._platform.async_send_message(message, title=title, target=self.entity_id)
462async def async_setup_entry(
463 hass: HomeAssistant,
464 entry: SupernotifyConfigEntry,
465 async_add_entities: AddConfigEntryEntitiesCallback,
466) -> None:
467 """Expose each configured recipient as its own notify entity.
469 Forwarded to from async_setup_entry in __init__.py once the SupernotifyAction (entry.
470 runtime_data) is fully initialized, so people_registry is already populated.
471 """
472 _ = hass
473 service = entry.runtime_data
474 async_add_entities(
475 RecipientNotifyEntity(f"{entry.entry_id}_recipient_{recipient.name}", recipient, service)
476 for recipient in service.context.people_registry.people.values()
477 )
480class SupernotifyAction(BaseNotificationService):
481 """Implement SuperNotify Action"""
483 def __init__(
484 self,
485 hass: HomeAssistant,
486 deliveries: dict[str, dict[str, Any]] | None = None,
487 template_path: str | None = None,
488 media_path: str | None = None,
489 media_url_prefix: str | None = None,
490 archive: dict[str, Any] | None = None,
491 housekeeping: dict[str, Any] | None = None,
492 recipients_discovery: bool = True,
493 mobile_discovery: bool = True,
494 recipients: list[dict[str, Any]] | None = None,
495 mobile_actions: dict[str, Any] | None = None,
496 scenarios: dict[str, dict[str, Any]] | None = None,
497 links: list[str] | None = None,
498 transport_configs: dict[str, Any] | None = None,
499 cameras: list[dict[str, Any]] | None = None,
500 dupe_check: dict[str, Any] | None = None,
501 snooze: dict[str, Any] | None = None,
502 ) -> None:
503 """Initialize the service."""
504 self.last_notification: Notification | None = None
505 self.failures: int = 0
506 self.housekeeping: dict[str, Any] = housekeeping or {}
507 self.sent: int = 0
508 hass_api = HomeAssistantAPI(hass)
510 self.context = Context(
511 hass_api,
512 PeopleRegistry(recipients or [], hass_api, discover=recipients_discovery, mobile_discovery=mobile_discovery),
513 ScenarioRegistry(scenarios or {}),
514 DeliveryRegistry(deliveries or {}, transport_configs or {}, TRANSPORTS),
515 DupeChecker(dupe_check or {}),
516 NotificationArchive(archive or {}, hass_api),
517 MediaStorage(
518 media_path,
519 media_url_prefix=media_url_prefix,
520 days=self.housekeeping.get(CONF_MEDIA_STORAGE_DAYS, 7),
521 ),
522 Snoozer(snooze),
523 links or [],
524 recipients or [],
525 mobile_actions,
526 template_path,
527 cameras=cameras,
528 )
530 self.exposed_entities: list[str] = []
532 async def initialize(self) -> None:
533 await self.context.initialize()
534 self.context.hass_api.initialize()
535 self.context.people_registry.initialize()
536 await self.context.delivery_registry.initialize(self.context)
537 await self.context.scenario_registry.initialize(
538 self.context.delivery_registry,
539 self.context.mobile_actions,
540 self.context.hass_api,
541 )
542 await self.context.archive.initialize()
543 await self.context.media_storage.initialize(self.context.hass_api)
545 self.expose_entities()
546 self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action)
547 self.context.hass_api.subscribe_state(self.exposed_entities, self._entity_state_change_listener)
549 housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME)
550 if housekeeping_schedule:
551 _LOGGER.info("SUPERNOTIFY Setting up housekeeping schedule at: %s", housekeeping_schedule)
552 self.context.hass_api.subscribe_time(
553 housekeeping_schedule.hour, housekeeping_schedule.minute, housekeeping_schedule.second, self.async_nightly_tasks
554 )
555 else:
556 _LOGGER.info(
557 "SUPERNOTIFY Housekeeping disabled. Storage must be manually managed if using attachments or image snapshots"
558 )
560 self.context.hass_api.subscribe_event(EVENT_HOMEASSISTANT_STOP, self.async_shutdown)
562 async def async_shutdown(self, event: Event) -> None:
563 _LOGGER.info("SUPERNOTIFY Shutting down, %s (%s)", event.event_type, event.time_fired)
564 self.shutdown()
566 async def async_unregister_services(self) -> None:
567 _LOGGER.info("SUPERNOTIFY Unregistering")
568 self.shutdown()
569 return await super().async_unregister_services()
571 def shutdown(self) -> None:
572 self.context.hass_api.disconnect()
573 _LOGGER.info("SUPERNOTIFY Shut down")
575 async def async_send_message(
576 self, message: str = "", title: str | None = None, target: list[str] | str | None = None, **kwargs: Any
577 ) -> None:
578 """Send a message via chosen transport."""
579 data = kwargs.get(ATTR_DATA, {})
580 notification = None
581 _LOGGER.debug("Message: %s, target: %s, data: %s", message, target, data)
583 try:
584 notification = Notification(self.context, message, title, target, data)
585 await notification.initialize()
586 if await notification.deliver():
587 self.sent += 1
588 self.context.hass_api.set_state(f"sensor.{DOMAIN}_notifications", self.sent)
589 elif notification.failed:
590 _LOGGER.error("SUPERNOTIFY Failed to deliver %s, error count %s", notification.id, notification.error_count)
591 else:
592 if notification.delivered == 0:
593 codes: list[SuppressionReason] = notification._skip_reasons
594 reason: str = ",".join(str(code) for code in codes)
595 problem: bool = codes != [SuppressionReason.DUPE]
596 else:
597 problem = True
598 reason = "No delivery envelopes generated"
599 if problem:
600 _LOGGER.warning("SUPERNOTIFY No deliveries made for %s: %s", notification.id, reason)
601 else:
602 _LOGGER.debug("SUPERNOTIFY Deliveries suppressed for %s: %s", notification.id, reason)
604 except Exception as err:
605 # fault barrier of last resort, integration failures should be caught within envelope delivery
606 _LOGGER.exception("SUPERNOTIFY Failed to send message %s", message)
607 self.failures += 1
608 if notification is not None:
609 notification._delivery_error = format_exception(err)
610 self.context.hass_api.set_state(f"sensor.{DOMAIN}_failures", self.failures)
612 if notification is None:
613 _LOGGER.warning("SUPERNOTIFY NULL Notification, %s", message)
614 else:
615 self.last_notification = notification
616 await self.context.archive.archive(notification)
617 _LOGGER.debug(
618 "SUPERNOTIFY %s deliveries, %s failed, %s skipped, %s suppressed",
619 notification.delivered,
620 notification.failed,
621 notification.skipped,
622 notification.suppressed,
623 )
625 async def _entity_state_change_listener(self, event: Event[EventStateChangedData]) -> None:
626 changes = 0
627 if event is not None:
628 _LOGGER.debug(f"SUPERNOTIFY {event.event_type} event for entity: {event.data}")
629 new_state: State | None = event.data["new_state"]
630 if new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_scenario_"):
631 scenario: Scenario | None = self.context.scenario_registry.scenarios.get(
632 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_scenario_", "")
633 )
634 if scenario is None:
635 _LOGGER.warning(f"SUPERNOTIFY Event for unknown scenario {event.data['entity_id']}")
636 else:
637 if new_state.state == "off" and scenario.enabled:
638 scenario.enabled = False
639 _LOGGER.info(f"SUPERNOTIFY Disabling scenario {scenario.name}")
640 changes += 1
641 elif new_state.state == "on" and not scenario.enabled:
642 scenario.enabled = True
643 _LOGGER.info(f"SUPERNOTIFY Enabling scenario {scenario.name}")
644 changes += 1
645 else:
646 _LOGGER.info(f"SUPERNOTIFY No change to scenario {scenario.name}, already {new_state}")
647 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_delivery_"):
648 delivery_name: str = event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_delivery_", "")
649 if new_state.state == "off":
650 if self.context.delivery_registry.disable(delivery_name):
651 changes += 1
652 elif new_state.state == "on":
653 if self.context.delivery_registry.enable(delivery_name):
654 changes += 1
655 else:
656 _LOGGER.info(f"SUPERNOTIFY No change to delivery {delivery_name} for state {new_state.state}")
657 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_transport_"):
658 transport: Transport | None = self.context.delivery_registry.transports.get(
659 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_transport_", "")
660 )
661 if transport is None:
662 _LOGGER.warning(f"SUPERNOTIFY Event for unknown transport {event.data['entity_id']}")
663 else:
664 if new_state.state == "off" and transport.enabled:
665 transport.enabled = False
666 _LOGGER.info(f"SUPERNOTIFY Disabling transport {transport.name}")
667 changes += 1
668 elif new_state.state == "on" and not transport.enabled:
669 transport.enabled = True
670 _LOGGER.info(f"SUPERNOTIFY Enabling transport {transport.name}")
671 changes += 1
672 else:
673 _LOGGER.info(f"SUPERNOTIFY No change to transport {transport.name}, already {new_state}")
674 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_recipient_"):
675 recipient: Recipient | None = self.context.people_registry.people.get(
676 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_recipient_", "person.")
677 )
678 if recipient is None:
679 _LOGGER.warning(f"SUPERNOTIFY Event for unknown recipient {event.data['entity_id']}")
680 else:
681 if new_state.state == "off" and recipient.enabled:
682 recipient.enabled = False
683 _LOGGER.info(f"SUPERNOTIFY Disabling recipient {recipient.entity_id}")
684 changes += 1
685 elif new_state.state == "on" and not recipient.enabled:
686 recipient.enabled = True
687 _LOGGER.info(f"SUPERNOTIFY Enabling recipient {recipient.entity_id}")
688 changes += 1
689 else:
690 _LOGGER.info(f"SUPERNOTIFY No change to recipient {recipient.entity_id}, already {new_state}")
692 else:
693 _LOGGER.warning("SUPERNOTIFY entity event with nothing to do:%s", event)
695 def expose_entity(
696 self,
697 entity_name: str,
698 state: str,
699 attributes: dict[str, Any],
700 platform: str = Platform.BINARY_SENSOR,
701 original_name: str | None = None,
702 original_icon: str | None = None,
703 entity_registry: er.EntityRegistry | None = None,
704 ) -> None:
705 """Expose a technical entity in Home Assistant representing internal state and attributes"""
706 entity_id: str
707 if entity_registry is not None:
708 try:
709 entry: er.RegistryEntry = entity_registry.async_get_or_create(
710 platform,
711 DOMAIN,
712 entity_name,
713 entity_category=EntityCategory.DIAGNOSTIC,
714 original_name=original_name,
715 original_icon=original_icon,
716 )
717 entity_id = entry.entity_id
718 except Exception as e:
719 _LOGGER.warning("SUPERNOTIFY Unable to register entity %s: %s", entity_name, e)
720 # continue anyway even if not registered as state is independent of entity
721 entity_id = f"{platform}.{DOMAIN}_{entity_name}"
722 try:
723 self.context.hass_api.set_state(entity_id, state, attributes)
724 self.exposed_entities.append(entity_id)
725 except Exception as e:
726 _LOGGER.error("SUPERNOTIFY Unable to set state for entity %s: %s", entity_id, e)
728 def expose_entities(self) -> None:
729 # Create on the fly entities for key internal config and state
730 ent_reg: er.EntityRegistry | None = self.context.hass_api.entity_registry()
731 if ent_reg is None:
732 _LOGGER.error("SUPERNOTIFY Unable to access entity registry to expose entities")
733 return
735 self.context.hass_api.set_state(f"sensor.{DOMAIN}_failures", self.failures)
736 self.context.hass_api.set_state(f"sensor.{DOMAIN}_notifications", self.sent)
738 for scenario in self.context.scenario_registry.scenarios.values():
739 self.expose_entity(
740 f"scenario_{scenario.name}",
741 state=STATE_UNKNOWN,
742 attributes=sanitize(scenario.attributes(include_condition=False)),
743 original_name=f"{scenario.name} Scenario",
744 original_icon="mdi:clipboard-text",
745 entity_registry=ent_reg,
746 )
747 for transport in self.context.delivery_registry.transports.values():
748 self.expose_entity(
749 f"transport_{transport.name}",
750 state=STATE_ON if transport.enabled else STATE_OFF,
751 attributes=sanitize(transport.attributes()),
752 original_name=f"{transport.name} Transport Adaptor",
753 original_icon="mdi:truck-fast",
754 entity_registry=ent_reg,
755 )
757 for delivery in self.context.delivery_registry.deliveries.values():
758 self.expose_entity(
759 f"delivery_{delivery.name}",
760 state=STATE_ON if delivery.enabled else STATE_OFF,
761 attributes=sanitize(delivery.attributes()),
762 original_name=f"{delivery.name} Delivery Configuration",
763 original_icon="mdi:package-variant",
764 entity_registry=ent_reg,
765 )
767 for recipient in self.context.people_registry.people.values():
768 self.expose_entity(
769 f"recipient_{recipient.name}",
770 state=STATE_ON if recipient.enabled else STATE_OFF,
771 attributes=sanitize(recipient.attributes()),
772 original_name=f"{recipient.name}",
773 original_icon="mdi:account-arrow-left",
774 entity_registry=ent_reg,
775 )
777 def enquire_implicit_deliveries(self) -> dict[str, Any]:
778 v: dict[str, list[str]] = {}
779 for t in self.context.delivery_registry.transports:
780 for d in self.context.delivery_registry.implicit_deliveries:
781 if d.transport.name == t:
782 v.setdefault(t, [])
783 v[t].append(d.name)
784 return v
786 def enquire_deliveries_by_scenario(self) -> dict[str, dict[str, list[str]]]:
787 return {
788 name: {
789 "enabled": scenario.enabling_deliveries(),
790 "disabled": scenario.disabling_deliveries(),
791 "applies": scenario.relevant_deliveries(),
792 }
793 for name, scenario in self.context.scenario_registry.scenarios.items()
794 if scenario.enabled
795 }
797 async def enquire_occupancy(self) -> dict[str, list[dict[str, Any]]]:
798 occupancy = self.context.people_registry.determine_occupancy()
799 return {k: [v.as_dict() for v in vs] for k, vs in occupancy.items()}
801 async def enquire_active_scenarios(self) -> list[str]:
802 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy()
803 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None)
804 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(cvars)]
806 async def trace_active_scenarios(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
807 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy()
808 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None)
810 def safe_json(v: Any) -> Any:
811 return json.loads(json.dumps(v, cls=ExtendedJSONEncoder))
813 enabled = []
814 disabled = []
815 dcvars = asdict(cvars)
816 for s in self.context.scenario_registry.scenarios.values():
817 if await s.trace(cvars):
818 enabled.append(safe_json(s.attributes(include_trace=True)))
819 else:
820 disabled.append(safe_json(s.attributes(include_trace=True)))
821 return enabled, disabled, dcvars
823 def enquire_scenarios(self) -> dict[str, dict[str, Any]]:
824 return {s.name: s.attributes(include_condition=False) for s in self.context.scenario_registry.scenarios.values()}
826 def enquire_snoozes(self) -> list[dict[str, Any]]:
827 return self.context.snoozer.export()
829 def clear_snoozes(self) -> int:
830 return self.context.snoozer.clear()
832 def enquire_recipients(self) -> list[dict[str, Any]]:
833 return [p.as_dict() for p in self.context.people_registry.people.values()]
835 @callback
836 def on_mobile_action(self, event: Event) -> None:
837 """Listen for mobile actions relevant to snooze and silence notifications
839 Example Action:
840 event_type: mobile_app_notification_action
841 data:
842 foo: a
843 origin: REMOTE
844 time_fired: "2024-04-20T13:14:09.360708+00:00"
845 context:
846 id: 01HVXT93JGWEDW0KE57Z0X6Z1K
847 parent_id: null
848 user_id: a9dbae1a5abf33dbbad52ff82201bb17
849 """
850 event_name = event.data.get(ATTR_ACTION)
851 if event_name is None or not event_name.startswith("SUPERNOTIFY_"):
852 return # event not intended for here
853 self.context.snoozer.handle_command_event(event, self.context.people_registry.enabled_recipients())
855 @callback
856 async def async_nightly_tasks(self, now: dt.datetime) -> None:
857 _LOGGER.info("SUPERNOTIFY Housekeeping starting as scheduled at %s", now)
858 await self.context.archive.cleanup()
859 self.context.snoozer.purge_snoozes()
860 await self.context.media_storage.cleanup()
861 _LOGGER.info("SUPERNOTIFY Housekeeping completed")