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

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

2 

3import datetime as dt 

4import json 

5import logging 

6from dataclasses import asdict 

7from traceback import format_exception 

8from typing import TYPE_CHECKING, Any 

9 

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 

29 

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 

78 

79if TYPE_CHECKING: 

80 from .scenario import Scenario 

81 

82PARALLEL_UPDATES = 0 

83 

84_LOGGER = logging.getLogger(__name__) 

85 

86SNOOZE_TIME = 60 * 60 # TODO: move to configuration 

87 

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 

102 

103 

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 

119 

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

139 

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 } 

158 

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

160 return service.expose_entities() 

161 

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

163 return service.enquire_implicit_deliveries() 

164 

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

166 return service.enquire_deliveries_by_scenario() 

167 

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

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

170 

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 

177 

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

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

180 

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

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

183 

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

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

186 

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

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

189 

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

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

192 

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 } 

205 

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 } 

218 

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 ) 

297 

298 return service 

299 

300 

301class SupernotifyEntity(NotifyEntity): 

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

303 

304 _attr_has_entity_name = True 

305 _attr_name = "supernotify" 

306 

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 

316 

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) 

322 

323 

324class SupernotifyAction(BaseNotificationService): 

325 """Implement SuperNotify Action""" 

326 

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 ) 

366 

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

368 

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) 

381 

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) 

385 

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 ) 

392 

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

394 

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

398 

399 async def async_unregister_services(self) -> None: 

400 _LOGGER.info("SUPERNOTIFY unregistering") 

401 self.shutdown() 

402 return await super().async_unregister_services() 

403 

404 def shutdown(self) -> None: 

405 self.context.hass_api.disconnect() 

406 _LOGGER.info("SUPERNOTIFY shut down") 

407 

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) 

415 

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) 

436 

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) 

444 

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 ) 

457 

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

524 

525 else: 

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

527 

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) 

560 

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 

567 

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) 

570 

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 ) 

589 

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 ) 

599 

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 ) 

609 

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 

618 

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 } 

629 

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

633 

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

638 

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) 

642 

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

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

645 

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 

655 

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

658 

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

660 return self.context.snoozer.export() 

661 

662 def clear_snoozes(self) -> int: 

663 return self.context.snoozer.clear() 

664 

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

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

667 

668 @callback 

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

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

671 

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

687 

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