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

1"""Supernotify service, extending BaseNotificationService""" 

2 

3from __future__ import annotations 

4 

5import json 

6import logging 

7from dataclasses import asdict 

8from traceback import format_exception 

9from typing import TYPE_CHECKING, Any, Final 

10 

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 

35 

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 

93 

94if TYPE_CHECKING: 

95 import datetime as dt 

96 

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 

100 

101 from . import SupernotifyConfigEntry 

102 from .scenario import Scenario 

103 from .transport import Transport 

104 

105PARALLEL_UPDATES = 0 

106 

107_LOGGER = logging.getLogger(__name__) 

108 

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 

132 

133 

134def build_supernotify_action(hass: HomeAssistant, config: ConfigType) -> SupernotifyAction: 

135 """Construct a SupernotifyAction from a fully validated FULL_CONFIG_SCHEMA config dict. 

136 

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 ) 

159 

160 

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) 

176 

177 

178@callback 

179def async_register_supplemental_services(hass: HomeAssistant, service: SupernotifyAction, config: ConfigType) -> None: 

180 """Register the domain-scoped supplemental/debugging/admin services. 

181 

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. 

187 

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 

196 

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 } 

216 

217 def supplemental_action_refresh_entities(_call: ServiceCall) -> None: 

218 return service.expose_entities() 

219 

220 def supplemental_action_enquire_implicit_deliveries(_call: ServiceCall) -> dict[str, Any]: 

221 return service.enquire_implicit_deliveries() 

222 

223 def supplemental_action_enquire_deliveries_by_scenario(_call: ServiceCall) -> dict[str, Any]: 

224 return service.enquire_deliveries_by_scenario() 

225 

226 def supplemental_action_enquire_last_notification(_call: ServiceCall) -> dict[str, Any]: 

227 return service.last_notification.contents() if service.last_notification else {} 

228 

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 

235 

236 def supplemental_action_enquire_scenarios(_call: ServiceCall) -> dict[str, Any]: 

237 return {"scenarios": service.enquire_scenarios()} 

238 

239 async def supplemental_action_enquire_occupancy(_call: ServiceCall) -> dict[str, Any]: 

240 return {"scenarios": await service.enquire_occupancy()} 

241 

242 def supplemental_action_enquire_snoozes(_call: ServiceCall) -> dict[str, Any]: 

243 return {"snoozes": service.enquire_snoozes()} 

244 

245 def supplemental_action_clear_snoozes(_call: ServiceCall) -> dict[str, Any]: 

246 return {"cleared": service.clear_snoozes()} 

247 

248 def supplemental_action_enquire_recipients(_call: ServiceCall) -> dict[str, Any]: 

249 return {"recipients": service.enquire_recipients()} 

250 

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 } 

263 

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 } 

276 

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 ) 

355 

356 

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) 

363 

364 

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. 

372 

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). 

378 

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 

390 

391 from .repairs import async_create_legacy_yaml_issue, async_sync_entry_from_legacy_config 

392 

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 

397 

398 

399class SupernotifyEntity(NotifyEntity): 

400 """Implement supernotify as a NotifyEntity platform.""" 

401 

402 _attr_has_entity_name = True 

403 _attr_name = "supernotify" 

404 

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 

414 

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) 

420 

421 

422class RecipientNotifyEntity(NotifyEntity): 

423 """Expose a single recipient as its own `notify.recipient_<name>` entity. 

424 

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 """ 

432 

433 _attr_has_entity_name = True 

434 

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}" 

448 

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 

452 

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() 

456 

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) 

460 

461 

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. 

468 

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 ) 

478 

479 

480class SupernotifyAction(BaseNotificationService): 

481 """Implement SuperNotify Action""" 

482 

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) 

509 

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 ) 

529 

530 self.exposed_entities: list[str] = [] 

531 

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) 

544 

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) 

548 

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 ) 

559 

560 self.context.hass_api.subscribe_event(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) 

561 

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() 

565 

566 async def async_unregister_services(self) -> None: 

567 _LOGGER.info("SUPERNOTIFY Unregistering") 

568 self.shutdown() 

569 return await super().async_unregister_services() 

570 

571 def shutdown(self) -> None: 

572 self.context.hass_api.disconnect() 

573 _LOGGER.info("SUPERNOTIFY Shut down") 

574 

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) 

582 

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) 

603 

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) 

611 

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 ) 

624 

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}") 

691 

692 else: 

693 _LOGGER.warning("SUPERNOTIFY entity event with nothing to do:%s", event) 

694 

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) 

727 

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 

734 

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) 

737 

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 ) 

756 

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 ) 

766 

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 ) 

776 

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 

785 

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 } 

796 

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()} 

800 

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)] 

805 

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) 

809 

810 def safe_json(v: Any) -> Any: 

811 return json.loads(json.dumps(v, cls=ExtendedJSONEncoder)) 

812 

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 

822 

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()} 

825 

826 def enquire_snoozes(self) -> list[dict[str, Any]]: 

827 return self.context.snoozer.export() 

828 

829 def clear_snoozes(self) -> int: 

830 return self.context.snoozer.clear() 

831 

832 def enquire_recipients(self) -> list[dict[str, Any]]: 

833 return [p.as_dict() for p in self.context.people_registry.people.values()] 

834 

835 @callback 

836 def on_mobile_action(self, event: Event) -> None: 

837 """Listen for mobile actions relevant to snooze and silence notifications 

838 

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()) 

854 

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")