Coverage for custom_components/supernotify/notify.py: 24%
314 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
1"""Supernotify service, extending BaseNotificationService"""
3import datetime as dt
4import json
5import logging
6from dataclasses import asdict
7from traceback import format_exception
8from typing import TYPE_CHECKING, Any
10from homeassistant.components.notify import (
11 NotifyEntity,
12 NotifyEntityFeature,
13)
14from homeassistant.components.notify.legacy import BaseNotificationService
15from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory, Platform
16from homeassistant.core import (
17 Event,
18 EventStateChangedData,
19 HomeAssistant,
20 ServiceCall,
21 State,
22 SupportsResponse,
23 callback,
24)
25from homeassistant.helpers import entity_registry as er
26from homeassistant.helpers.json import ExtendedJSONEncoder
27from homeassistant.helpers.reload import async_setup_reload_service
28from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
30from . import DOMAIN, PLATFORMS
31from .archive import ARCHIVE_PURGE_MIN_INTERVAL, NotificationArchive
32from .common import DupeChecker, sanitize
33from .const import (
34 ATTR_ACTION,
35 ATTR_DATA,
36 CONF_ACTION_GROUPS,
37 CONF_ACTIONS,
38 CONF_ARCHIVE,
39 CONF_CAMERAS,
40 CONF_DELIVERY,
41 CONF_DUPE_CHECK,
42 CONF_HOUSEKEEPING,
43 CONF_HOUSEKEEPING_TIME,
44 CONF_LINKS,
45 CONF_MEDIA_PATH,
46 CONF_MEDIA_STORAGE_DAYS,
47 CONF_MOBILE_DISCOVERY,
48 CONF_RECIPIENTS,
49 CONF_RECIPIENTS_DISCOVERY,
50 CONF_SCENARIOS,
51 CONF_TEMPLATE_PATH,
52 CONF_TRANSPORTS,
53 PRIORITY_MEDIUM,
54)
55from .context import Context
56from .delivery import DeliveryRegistry
57from .hass_api import HomeAssistantAPI
58from .media_grab import MediaStorage
59from .model import ConditionVariables, SuppressionReason
60from .notification import Notification
61from .people import PeopleRegistry, Recipient
62from .scenario import ScenarioRegistry
63from .schema import SUPERNOTIFY_SCHEMA as PLATFORM_SCHEMA
64from .snoozer import Snoozer
65from .transport import Transport
66from .transports.alexa_devices import AlexaDevicesTransport
67from .transports.alexa_media_player import AlexaMediaPlayerTransport
68from .transports.chime import ChimeTransport
69from .transports.email import EmailTransport
70from .transports.generic import GenericTransport
71from .transports.media_player import MediaPlayerTransport
72from .transports.mobile_push import MobilePushTransport
73from .transports.mqtt import MQTTTransport
74from .transports.notify_entity import NotifyEntityTransport
75from .transports.persistent import PersistentTransport
76from .transports.sms import SMSTransport
77from .transports.tts import TTSTransport
79if TYPE_CHECKING:
80 from .scenario import Scenario
82PARALLEL_UPDATES = 0
84_LOGGER = logging.getLogger(__name__)
86SNOOZE_TIME = 60 * 60 # TODO: move to configuration
88TRANSPORTS: list[type[Transport]] = [
89 EmailTransport,
90 SMSTransport,
91 MQTTTransport,
92 AlexaDevicesTransport,
93 AlexaMediaPlayerTransport,
94 MobilePushTransport,
95 MediaPlayerTransport,
96 ChimeTransport,
97 PersistentTransport,
98 GenericTransport,
99 TTSTransport,
100 NotifyEntityTransport,
101] # No auto-discovery of transport plugins so manual class registration required here
104async def async_get_service(
105 hass: HomeAssistant,
106 config: ConfigType,
107 discovery_info: DiscoveryInfoType | None = None,
108) -> "SupernotifyAction":
109 """Notify specific component setup - see async_setup_legacy in legacy BaseNotificationService"""
110 _ = PLATFORM_SCHEMA # schema must be imported even if not used for HA platform detection
111 _ = discovery_info
112 # for delivery in config.get(CONF_DELIVERY, {}).values():
113 # if delivery and CONF_CONDITION in delivery:
114 # try:
115 # await async_validate_condition_config(hass, delivery[CONF_CONDITION])
116 # except Exception as e:
117 # _LOGGER.error("SUPERNOTIFY delivery %s fails condition: %s", delivery[CONF_CONDITION], e)
118 # raise
120 await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
121 service = SupernotifyAction(
122 hass,
123 deliveries=config[CONF_DELIVERY],
124 template_path=config[CONF_TEMPLATE_PATH],
125 media_path=config[CONF_MEDIA_PATH],
126 archive=config[CONF_ARCHIVE],
127 housekeeping=config[CONF_HOUSEKEEPING],
128 mobile_discovery=config[CONF_MOBILE_DISCOVERY],
129 recipients_discovery=config[CONF_RECIPIENTS_DISCOVERY],
130 recipients=config[CONF_RECIPIENTS],
131 mobile_actions=config[CONF_ACTION_GROUPS],
132 scenarios=config[CONF_SCENARIOS],
133 links=config[CONF_LINKS],
134 transport_configs=config[CONF_TRANSPORTS],
135 cameras=config[CONF_CAMERAS],
136 dupe_check=config[CONF_DUPE_CHECK],
137 )
138 await service.initialize()
140 def supplemental_action_enquire_configuration(_call: ServiceCall) -> dict[str, Any]:
141 return {
142 CONF_DELIVERY: config.get(CONF_DELIVERY, {}),
143 CONF_LINKS: config.get(CONF_LINKS, ()),
144 CONF_TEMPLATE_PATH: config.get(CONF_TEMPLATE_PATH, None),
145 CONF_MEDIA_PATH: config.get(CONF_MEDIA_PATH, None),
146 CONF_ARCHIVE: config.get(CONF_ARCHIVE, {}),
147 CONF_MOBILE_DISCOVERY: config.get(CONF_MOBILE_DISCOVERY, ()),
148 CONF_RECIPIENTS_DISCOVERY: config.get(CONF_RECIPIENTS_DISCOVERY, ()),
149 CONF_RECIPIENTS: config.get(CONF_RECIPIENTS, ()),
150 CONF_ACTIONS: config.get(CONF_ACTIONS, {}),
151 CONF_HOUSEKEEPING: config.get(CONF_HOUSEKEEPING, {}),
152 CONF_ACTION_GROUPS: config.get(CONF_ACTION_GROUPS, {}),
153 CONF_SCENARIOS: list(config.get(CONF_SCENARIOS, {}).keys()),
154 CONF_TRANSPORTS: config.get(CONF_TRANSPORTS, {}),
155 CONF_CAMERAS: config.get(CONF_CAMERAS, {}),
156 CONF_DUPE_CHECK: config.get(CONF_DUPE_CHECK, {}),
157 }
159 def supplemental_action_refresh_entities(_call: ServiceCall) -> None:
160 return service.expose_entities()
162 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]:
163 return service.enquire_implicit_deliveries()
165 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]:
166 return service.enquire_deliveries_by_scenario()
168 def supplemental_action_enquire_last_notification(_call: ServiceCall) -> dict[str, Any]:
169 return service.last_notification.contents() if service.last_notification else {}
171 async def supplemental_action_enquire_active_scenarios(call: ServiceCall) -> dict[str, Any]:
172 trace = call.data.get("trace", False)
173 result: dict[str, Any] = {"scenarios": await service.enquire_active_scenarios()}
174 if trace:
175 result["trace"] = await service.trace_active_scenarios()
176 return result
178 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]:
179 return {"scenarios": service.enquire_scenarios()}
181 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]:
182 return {"scenarios": await service.enquire_occupancy()}
184 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]:
185 return {"snoozes": service.enquire_snoozes()}
187 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]:
188 return {"cleared": service.clear_snoozes()}
190 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]:
191 return {"recipients": service.enquire_recipients()}
193 async def supplemental_action_purge_archive(call: ServiceCall) -> dict[str, Any]:
194 days = call.data.get("days")
195 if not service.context.archive.enabled:
196 return {"error": "No archive configured"}
197 purged = await service.context.archive.cleanup(days=days, force=True)
198 arch_size = await service.context.archive.size()
199 return {
200 "purged": purged,
201 "remaining": arch_size,
202 "interval": ARCHIVE_PURGE_MIN_INTERVAL,
203 "days": service.context.archive.archive_days if days is None else days,
204 }
206 async def supplemental_action_purge_media(call: ServiceCall) -> dict[str, Any]:
207 days = call.data.get("days")
208 if not service.context.media_storage.media_path:
209 return {"error": "No media storage configured"}
210 purged = await service.context.media_storage.cleanup(days=days, force=True)
211 size = await service.context.media_storage.size()
212 return {
213 "purged": purged,
214 "remaining": size,
215 "interval": service.context.media_storage.purge_minute_interval,
216 "days": service.context.media_storage.days if days is None else days,
217 }
219 hass.services.async_register(
220 DOMAIN,
221 "enquire_configuration",
222 supplemental_action_enquire_configuration,
223 supports_response=SupportsResponse.ONLY,
224 )
225 hass.services.async_register(
226 DOMAIN,
227 "enquire_implicit_deliveries",
228 supplemental_action_enquire_implicit_deliveries,
229 supports_response=SupportsResponse.ONLY,
230 )
231 hass.services.async_register(
232 DOMAIN,
233 "enquire_deliveries_by_scenario",
234 supplemental_action_enquire_deliveries_by_scenario,
235 supports_response=SupportsResponse.ONLY,
236 )
237 hass.services.async_register(
238 DOMAIN,
239 "enquire_last_notification",
240 supplemental_action_enquire_last_notification,
241 supports_response=SupportsResponse.ONLY,
242 )
243 hass.services.async_register(
244 DOMAIN,
245 "enquire_active_scenarios",
246 supplemental_action_enquire_active_scenarios,
247 supports_response=SupportsResponse.ONLY,
248 )
249 hass.services.async_register(
250 DOMAIN,
251 "enquire_scenarios",
252 supplemental_action_enquire_scenarios,
253 supports_response=SupportsResponse.ONLY,
254 )
255 hass.services.async_register(
256 DOMAIN,
257 "enquire_occupancy",
258 supplemental_action_enquire_occupancy,
259 supports_response=SupportsResponse.ONLY,
260 )
261 hass.services.async_register(
262 DOMAIN,
263 "enquire_recipients",
264 supplemental_action_enquire_recipients,
265 supports_response=SupportsResponse.ONLY,
266 )
267 hass.services.async_register(
268 DOMAIN,
269 "enquire_snoozes",
270 supplemental_action_enquire_snoozes,
271 supports_response=SupportsResponse.ONLY,
272 )
273 hass.services.async_register(
274 DOMAIN,
275 "clear_snoozes",
276 supplemental_action_clear_snoozes,
277 supports_response=SupportsResponse.ONLY,
278 )
279 hass.services.async_register(
280 DOMAIN,
281 "purge_archive",
282 supplemental_action_purge_archive,
283 supports_response=SupportsResponse.ONLY,
284 )
285 hass.services.async_register(
286 DOMAIN,
287 "purge_media",
288 supplemental_action_purge_media,
289 supports_response=SupportsResponse.ONLY,
290 )
291 hass.services.async_register(
292 DOMAIN,
293 "refresh_entities",
294 supplemental_action_refresh_entities,
295 supports_response=SupportsResponse.NONE,
296 )
298 return service
301class SupernotifyEntity(NotifyEntity):
302 """Implement supernotify as a NotifyEntity platform."""
304 _attr_has_entity_name = True
305 _attr_name = "supernotify"
307 def __init__(
308 self,
309 unique_id: str,
310 platform: "SupernotifyAction",
311 ) -> None:
312 """Initialize the SuperNotify entity."""
313 self._attr_unique_id = unique_id
314 self._attr_supported_features = NotifyEntityFeature.TITLE
315 self._platform = platform
317 async def async_send_message(
318 self, message: str, title: str | None = None, target: str | list[str] | None = None, data: dict[str, Any] | None = None
319 ) -> None:
320 """Send a message to a user."""
321 await self._platform.async_send_message(message, title=title, target=target, data=data)
324class SupernotifyAction(BaseNotificationService):
325 """Implement SuperNotify Action"""
327 def __init__(
328 self,
329 hass: HomeAssistant,
330 deliveries: dict[str, dict[str, Any]] | None = None,
331 template_path: str | None = None,
332 media_path: str | None = None,
333 archive: dict[str, Any] | None = None,
334 housekeeping: dict[str, Any] | None = None,
335 recipients_discovery: bool = True,
336 mobile_discovery: bool = True,
337 recipients: list[dict[str, Any]] | None = None,
338 mobile_actions: dict[str, Any] | None = None,
339 scenarios: dict[str, dict[str, Any]] | None = None,
340 links: list[str] | None = None,
341 transport_configs: dict[str, Any] | None = None,
342 cameras: list[dict[str, Any]] | None = None,
343 dupe_check: dict[str, Any] | None = None,
344 ) -> None:
345 """Initialize the service."""
346 self.last_notification: Notification | None = None
347 self.failures: int = 0
348 self.housekeeping: dict[str, Any] = housekeeping or {}
349 self.sent: int = 0
350 hass_api = HomeAssistantAPI(hass)
351 self.context = Context(
352 hass_api,
353 PeopleRegistry(recipients or [], hass_api, discover=recipients_discovery, mobile_discovery=mobile_discovery),
354 ScenarioRegistry(scenarios or {}),
355 DeliveryRegistry(deliveries or {}, transport_configs or {}, TRANSPORTS),
356 DupeChecker(dupe_check or {}),
357 NotificationArchive(archive or {}, hass_api),
358 MediaStorage(media_path, self.housekeeping.get(CONF_MEDIA_STORAGE_DAYS, 7)),
359 Snoozer(),
360 links or [],
361 recipients or [],
362 mobile_actions,
363 template_path,
364 cameras=cameras,
365 )
367 self.exposed_entities: list[str] = []
369 async def initialize(self) -> None:
370 await self.context.initialize()
371 self.context.hass_api.initialize()
372 self.context.people_registry.initialize()
373 await self.context.delivery_registry.initialize(self.context)
374 await self.context.scenario_registry.initialize(
375 self.context.delivery_registry,
376 self.context.mobile_actions,
377 self.context.hass_api,
378 )
379 await self.context.archive.initialize()
380 await self.context.media_storage.initialize(self.context.hass_api)
382 self.expose_entities()
383 self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action)
384 self.context.hass_api.subscribe_state(self.exposed_entities, self._entity_state_change_listener)
386 housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME)
387 if housekeeping_schedule:
388 _LOGGER.info("SUPERNOTIFY setting up housekeeping schedule at: %s", housekeeping_schedule)
389 self.context.hass_api.subscribe_time(
390 housekeeping_schedule.hour, housekeeping_schedule.minute, housekeeping_schedule.second, self.async_nightly_tasks
391 )
393 self.context.hass_api.subscribe_event(EVENT_HOMEASSISTANT_STOP, self.async_shutdown)
395 async def async_shutdown(self, event: Event) -> None:
396 _LOGGER.info("SUPERNOTIFY shutting down, %s (%s)", event.event_type, event.time_fired)
397 self.shutdown()
399 async def async_unregister_services(self) -> None:
400 _LOGGER.info("SUPERNOTIFY unregistering")
401 self.shutdown()
402 return await super().async_unregister_services()
404 def shutdown(self) -> None:
405 self.context.hass_api.disconnect()
406 _LOGGER.info("SUPERNOTIFY shut down")
408 async def async_send_message(
409 self, message: str = "", title: str | None = None, target: list[str] | str | None = None, **kwargs: Any
410 ) -> None:
411 """Send a message via chosen transport."""
412 data = kwargs.get(ATTR_DATA, {})
413 notification = None
414 _LOGGER.debug("Message: %s, target: %s, data: %s", message, target, data)
416 try:
417 notification = Notification(self.context, message, title, target, data)
418 await notification.initialize()
419 if await notification.deliver():
420 self.sent += 1
421 self.context.hass_api.set_state(f"sensor.{DOMAIN}_notifications", self.sent)
422 elif notification.failed:
423 _LOGGER.error("SUPERNOTIFY Failed to deliver %s, error count %s", notification.id, notification.error_count)
424 else:
425 if notification.delivered == 0:
426 codes: list[SuppressionReason] = notification._skip_reasons
427 reason: str = ",".join(str(code) for code in codes)
428 problem: bool = codes != [SuppressionReason.DUPE]
429 else:
430 problem = True
431 reason = "No delivery envelopes generated"
432 if problem:
433 _LOGGER.warning("SUPERNOTIFY No deliveries made for %s: %s", notification.id, reason)
434 else:
435 _LOGGER.debug("SUPERNOTIFY Deliveries suppressed for %s: %s", notification.id, reason)
437 except Exception as err:
438 # fault barrier of last resort, integration failures should be caught within envelope delivery
439 _LOGGER.exception("SUPERNOTIFY Failed to send message %s", message)
440 self.failures += 1
441 if notification is not None:
442 notification._delivery_error = format_exception(err)
443 self.context.hass_api.set_state(f"sensor.{DOMAIN}_failures", self.failures)
445 if notification is None:
446 _LOGGER.warning("SUPERNOTIFY NULL Notification, %s", message)
447 else:
448 self.last_notification = notification
449 await self.context.archive.archive(notification)
450 _LOGGER.debug(
451 "SUPERNOTIFY %s deliveries, %s failed, %s skipped, % suppressed",
452 notification.delivered,
453 notification.failed,
454 notification.skipped,
455 notification.suppressed,
456 )
458 async def _entity_state_change_listener(self, event: Event[EventStateChangedData]) -> None:
459 changes = 0
460 if event is not None:
461 _LOGGER.debug(f"SUPERNOTIFY {event.event_type} event for entity: {event.data}")
462 new_state: State | None = event.data["new_state"]
463 if new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_scenario_"):
464 scenario: Scenario | None = self.context.scenario_registry.scenarios.get(
465 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_scenario_", "")
466 )
467 if scenario is None:
468 _LOGGER.warning(f"SUPERNOTIFY Event for unknown scenario {event.data['entity_id']}")
469 else:
470 if new_state.state == "off" and scenario.enabled:
471 scenario.enabled = False
472 _LOGGER.info(f"SUPERNOTIFY Disabling scenario {scenario.name}")
473 changes += 1
474 elif new_state.state == "on" and not scenario.enabled:
475 scenario.enabled = True
476 _LOGGER.info(f"SUPERNOTIFY Enabling scenario {scenario.name}")
477 changes += 1
478 else:
479 _LOGGER.info(f"SUPERNOTIFY No change to scenario {scenario.name}, already {new_state}")
480 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_delivery_"):
481 delivery_name: str = event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_delivery_", "")
482 if new_state.state == "off":
483 if self.context.delivery_registry.disable(delivery_name):
484 changes += 1
485 elif new_state.state == "on":
486 if self.context.delivery_registry.enable(delivery_name):
487 changes += 1
488 else:
489 _LOGGER.info(f"SUPERNOTIFY No change to delivery {delivery_name} for state {new_state.state}")
490 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_transport_"):
491 transport: Transport | None = self.context.delivery_registry.transports.get(
492 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_transport_", "")
493 )
494 if transport is None:
495 _LOGGER.warning(f"SUPERNOTIFY Event for unknown transport {event.data['entity_id']}")
496 else:
497 if new_state.state == "off" and transport.enabled:
498 transport.enabled = False
499 _LOGGER.info(f"SUPERNOTIFY Disabling transport {transport.name}")
500 changes += 1
501 elif new_state.state == "on" and not transport.enabled:
502 transport.enabled = True
503 _LOGGER.info(f"SUPERNOTIFY Enabling transport {transport.name}")
504 changes += 1
505 else:
506 _LOGGER.info(f"SUPERNOTIFY No change to transport {transport.name}, already {new_state}")
507 elif new_state and event.data["entity_id"].startswith(f"binary_sensor.{DOMAIN}_recipient_"):
508 recipient: Recipient | None = self.context.people_registry.people.get(
509 event.data["entity_id"].replace(f"binary_sensor.{DOMAIN}_recipient_", "person.")
510 )
511 if recipient is None:
512 _LOGGER.warning(f"SUPERNOTIFY Event for unknown recipient {event.data['entity_id']}")
513 else:
514 if new_state.state == "off" and recipient.enabled:
515 recipient.enabled = False
516 _LOGGER.info(f"SUPERNOTIFY Disabling recipient {recipient.entity_id}")
517 changes += 1
518 elif new_state.state == "on" and not recipient.enabled:
519 recipient.enabled = True
520 _LOGGER.info(f"SUPERNOTIFY Enabling recipient {recipient.entity_id}")
521 changes += 1
522 else:
523 _LOGGER.info(f"SUPERNOTIFY No change to recipient {recipient.entity_id}, already {new_state}")
525 else:
526 _LOGGER.warning("SUPERNOTIFY entity event with nothing to do:%s", event)
528 def expose_entity(
529 self,
530 entity_name: str,
531 state: str,
532 attributes: dict[str, Any],
533 platform: str = Platform.BINARY_SENSOR,
534 original_name: str | None = None,
535 original_icon: str | None = None,
536 entity_registry: er.EntityRegistry | None = None,
537 ) -> None:
538 """Expose a technical entity in Home Assistant representing internal state and attributes"""
539 entity_id: str
540 if entity_registry is not None:
541 try:
542 entry: er.RegistryEntry = entity_registry.async_get_or_create(
543 platform,
544 DOMAIN,
545 entity_name,
546 entity_category=EntityCategory.DIAGNOSTIC,
547 original_name=original_name,
548 original_icon=original_icon,
549 )
550 entity_id = entry.entity_id
551 except Exception as e:
552 _LOGGER.warning("SUPERNOTIFY Unable to register entity %s: %s", entity_name, e)
553 # continue anyway even if not registered as state is independent of entity
554 entity_id = f"{platform}.{DOMAIN}_{entity_name}"
555 try:
556 self.context.hass_api.set_state(entity_id, state, attributes)
557 self.exposed_entities.append(entity_id)
558 except Exception as e:
559 _LOGGER.error("SUPERNOTIFY Unable to set state for entity %s: %s", entity_id, e)
561 def expose_entities(self) -> None:
562 # Create on the fly entities for key internal config and state
563 ent_reg: er.EntityRegistry | None = self.context.hass_api.entity_registry()
564 if ent_reg is None:
565 _LOGGER.error("SUPERNOTIFY Unable to access entity registry to expose entities")
566 return
568 self.context.hass_api.set_state(f"sensor.{DOMAIN}_failures", self.failures)
569 self.context.hass_api.set_state(f"sensor.{DOMAIN}_notifications", self.sent)
571 for scenario in self.context.scenario_registry.scenarios.values():
572 self.expose_entity(
573 f"scenario_{scenario.name}",
574 state=STATE_UNKNOWN,
575 attributes=sanitize(scenario.attributes(include_condition=False)),
576 original_name=f"{scenario.name} Scenario",
577 original_icon="mdi:assignment",
578 entity_registry=ent_reg,
579 )
580 for transport in self.context.delivery_registry.transports.values():
581 self.expose_entity(
582 f"transport_{transport.name}",
583 state=STATE_ON if transport.enabled else STATE_OFF,
584 attributes=sanitize(transport.attributes()),
585 original_name=f"{transport.name} Transport Adaptor",
586 original_icon="mdi:delivery-truck-speed",
587 entity_registry=ent_reg,
588 )
590 for delivery in self.context.delivery_registry.deliveries.values():
591 self.expose_entity(
592 f"delivery_{delivery.name}",
593 state=STATE_ON if delivery.enabled else STATE_OFF,
594 attributes=sanitize(delivery.attributes()),
595 original_name=f"{delivery.name} Delivery Configuration",
596 original_icon="mdi:package_2",
597 entity_registry=ent_reg,
598 )
600 for recipient in self.context.people_registry.people.values():
601 self.expose_entity(
602 f"recipient_{recipient.name}",
603 state=STATE_ON if recipient.enabled else STATE_OFF,
604 attributes=sanitize(recipient.attributes()),
605 original_name=f"{recipient.name}",
606 original_icon="mdi:inbox_text_person",
607 entity_registry=ent_reg,
608 )
610 def enquire_implicit_deliveries(self) -> dict[str, Any]:
611 v: dict[str, list[str]] = {}
612 for t in self.context.delivery_registry.transports:
613 for d in self.context.delivery_registry.implicit_deliveries:
614 if d.transport.name == t:
615 v.setdefault(t, [])
616 v[t].append(d.name)
617 return v
619 def enquire_deliveries_by_scenario(self) -> dict[str, dict[str, list[str]]]:
620 return {
621 name: {
622 "enabled": scenario.enabling_deliveries(),
623 "disabled": scenario.disabling_deliveries(),
624 "applies": scenario.relevant_deliveries(),
625 }
626 for name, scenario in self.context.scenario_registry.scenarios.items()
627 if scenario.enabled
628 }
630 async def enquire_occupancy(self) -> dict[str, list[dict[str, Any]]]:
631 occupancy = self.context.people_registry.determine_occupancy()
632 return {k: [v.as_dict() for v in vs] for k, vs in occupancy.items()}
634 async def enquire_active_scenarios(self) -> list[str]:
635 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy()
636 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None)
637 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(cvars)]
639 async def trace_active_scenarios(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
640 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy()
641 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None)
643 def safe_json(v: Any) -> Any:
644 return json.loads(json.dumps(v, cls=ExtendedJSONEncoder))
646 enabled = []
647 disabled = []
648 dcvars = asdict(cvars)
649 for s in self.context.scenario_registry.scenarios.values():
650 if await s.trace(cvars):
651 enabled.append(safe_json(s.attributes(include_trace=True)))
652 else:
653 disabled.append(safe_json(s.attributes(include_trace=True)))
654 return enabled, disabled, dcvars
656 def enquire_scenarios(self) -> dict[str, dict[str, Any]]:
657 return {s.name: s.attributes(include_condition=False) for s in self.context.scenario_registry.scenarios.values()}
659 def enquire_snoozes(self) -> list[dict[str, Any]]:
660 return self.context.snoozer.export()
662 def clear_snoozes(self) -> int:
663 return self.context.snoozer.clear()
665 def enquire_recipients(self) -> list[dict[str, Any]]:
666 return [p.as_dict() for p in self.context.people_registry.people.values()]
668 @callback
669 def on_mobile_action(self, event: Event) -> None:
670 """Listen for mobile actions relevant to snooze and silence notifications
672 Example Action:
673 event_type: mobile_app_notification_action
674 data:
675 foo: a
676 origin: REMOTE
677 time_fired: "2024-04-20T13:14:09.360708+00:00"
678 context:
679 id: 01HVXT93JGWEDW0KE57Z0X6Z1K
680 parent_id: null
681 user_id: a9dbae1a5abf33dbbad52ff82201bb17
682 """
683 event_name = event.data.get(ATTR_ACTION)
684 if event_name is None or not event_name.startswith("SUPERNOTIFY_"):
685 return # event not intended for here
686 self.context.snoozer.handle_command_event(event, self.context.people_registry.enabled_recipients())
688 @callback
689 async def async_nightly_tasks(self, now: dt.datetime) -> None:
690 _LOGGER.info("SUPERNOTIFY Housekeeping starting as scheduled at %s", now)
691 await self.context.archive.cleanup()
692 self.context.snoozer.purge_snoozes()
693 await self.context.media_storage.cleanup()
694 _LOGGER.info("SUPERNOTIFY Housekeeping completed")