Coverage for custom_components/supernotify/engine.py: 98%

197 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +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 

10 

11from homeassistant.const import ( 

12 EVENT_HOMEASSISTANT_STOP, 

13) 

14from homeassistant.core import ( 

15 Context as HAContext, 

16) 

17from homeassistant.core import ( 

18 Event, 

19 HomeAssistant, 

20 callback, 

21) 

22from homeassistant.helpers.json import ExtendedJSONEncoder 

23from homeassistant.helpers.typing import ConfigType 

24 

25from .archive import NotificationArchive 

26from .common import DupeChecker 

27from .const import ( 

28 ATTR_ACTION, 

29 ATTR_DATA, 

30 CONF_ACTION_GROUPS, 

31 CONF_ARCHIVE, 

32 CONF_CAMERAS, 

33 CONF_DELIVERY, 

34 CONF_DELIVERY_CONTROL, 

35 CONF_DUPE_CHECK, 

36 CONF_HOUSEKEEPING, 

37 CONF_HOUSEKEEPING_TIME, 

38 CONF_LINKS, 

39 CONF_MEDIA_PATH, 

40 CONF_MEDIA_STORAGE_DAYS, 

41 CONF_MEDIA_URL_PREFIX, 

42 CONF_MOBILE_DISCOVERY, 

43 CONF_RECIPIENTS, 

44 CONF_RECIPIENTS_DISCOVERY, 

45 CONF_SCENARIO_CONTROL, 

46 CONF_SCENARIOS, 

47 CONF_SNOOZE, 

48 CONF_TEMPLATE_PATH, 

49 CONF_TRANSPORTS, 

50 OVERRIDE_KIND_DELIVERY, 

51 OVERRIDE_KIND_RECIPIENT, 

52 OVERRIDE_KIND_SCENARIO, 

53 OVERRIDE_KIND_TRANSPORT, 

54 OVERRIDE_KINDS, 

55 PRIORITY_MEDIUM, 

56) 

57from .context import Context 

58from .delivery import DeliveryRegistry 

59from .exceptions import UncategorizedTargetError 

60from .hass_api import HomeAssistantAPI 

61from .media_grab import MediaStorage 

62from .model import ConditionVariables, SuppressionReason 

63from .notification import Notification 

64from .people import PeopleRegistry, Recipient 

65from .scenario import ScenarioRegistry 

66from .sensor import SupernotifyCounterSensor 

67from .snoozer import Snoozer 

68from .static_config import TRANSPORTS 

69 

70if TYPE_CHECKING: 

71 import datetime as dt 

72 from collections.abc import Iterable 

73 

74 from .switch import Overridable, SupernotifyOverridableSwitch 

75 

76_LOGGER = logging.getLogger(__name__) 

77 

78 

79class SupernotifyEngine: 

80 """Owns the Context/registries/transports and actually delivers notifications. 

81 

82 This is the shared engine behind every entrypoint - notify.supernotify (via the 

83 SuperNotificationService legacy shim), supernotify.notify, and the NotifyEntity platform 

84 (RecipientNotifyEntity) - so it deliberately has no dependency on 

85 BaseNotificationService or anything else specific to the legacy notify platform. If/when 

86 HA core drops BaseNotificationService, only SuperNotificationService and its wiring in 

87 __init__.py need to go; this class and everything else built on it are unaffected. 

88 """ 

89 

90 def __init__( 

91 self, 

92 hass: HomeAssistant, 

93 deliveries: dict[str, dict[str, Any]] | None = None, 

94 template_path: str | None = None, 

95 media_path: str | None = None, 

96 media_url_prefix: str | None = None, 

97 archive: dict[str, Any] | None = None, 

98 housekeeping: dict[str, Any] | None = None, 

99 recipients_discovery: bool = True, 

100 mobile_discovery: bool = True, 

101 recipients: list[dict[str, Any]] | None = None, 

102 mobile_actions: dict[str, Any] | None = None, 

103 scenarios: dict[str, dict[str, Any]] | None = None, 

104 links: list[str] | None = None, 

105 transport_configs: dict[str, Any] | None = None, 

106 cameras: list[dict[str, Any]] | None = None, 

107 dupe_check: dict[str, Any] | None = None, 

108 snooze: dict[str, Any] | None = None, 

109 scenario_control: dict[str, Any] | None = None, 

110 delivery_control: dict[str, Any] | None = None, 

111 ) -> None: 

112 """Initialize the service.""" 

113 self.last_notification: Notification | None = None 

114 self.housekeeping: dict[str, Any] = housekeeping or {} 

115 # The counts live only in these entities, which restore their own value across restarts; 

116 # sensor.py hands them to Home Assistant once its platform loads 

117 self.notifications_sensor = SupernotifyCounterSensor("notifications", "notifications") 

118 self.failures_sensor = SupernotifyCounterSensor("failures", "failures") 

119 # Every switch overriding a configured enabled flag, by unique_id - populated by switch.py 

120 # as each is added to Home Assistant 

121 self.override_switches: dict[str, SupernotifyOverridableSwitch] = {} 

122 hass_api = HomeAssistantAPI(hass) 

123 

124 people_registry = PeopleRegistry( 

125 recipients or [], hass_api, discover=recipients_discovery, mobile_discovery=mobile_discovery 

126 ) 

127 self.context = Context( 

128 hass_api, 

129 people_registry, 

130 ScenarioRegistry(scenarios or {}, scenario_control, people_registry), 

131 DeliveryRegistry(deliveries or {}, transport_configs or {}, TRANSPORTS, delivery_control=delivery_control), 

132 DupeChecker(dupe_check or {}), 

133 NotificationArchive(archive or {}, hass_api), 

134 MediaStorage( 

135 media_path, 

136 media_url_prefix=media_url_prefix, 

137 days=self.housekeeping.get(CONF_MEDIA_STORAGE_DAYS, 7), 

138 ), 

139 Snoozer(snooze), 

140 links or [], 

141 recipients or [], 

142 mobile_actions, 

143 template_path, 

144 cameras=cameras, 

145 ) 

146 

147 async def initialize(self) -> None: 

148 await self.context.initialize() 

149 self.context.hass_api.initialize() 

150 await self.context.people_registry.initialize() 

151 await self.context.delivery_registry.initialize(self.context) 

152 await self.context.scenario_registry.initialize( 

153 self.context.delivery_registry, 

154 self.context.mobile_actions, 

155 self.context.hass_api, 

156 ) 

157 await self.context.archive.initialize() 

158 await self.context.media_storage.initialize(self.context.hass_api) 

159 await self.context.snoozer.initialize(self.context.hass_api) 

160 

161 # Every entity - switches, binary_sensors and counters - is a real platform entity, added 

162 # once this method returns (see __init__.py), and keeps its own state current 

163 self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action) 

164 

165 housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME) 

166 if housekeeping_schedule: 

167 _LOGGER.info("SUPERNOTIFY Setting up housekeeping schedule at: %s", housekeeping_schedule) 

168 self.context.hass_api.subscribe_time( 

169 housekeeping_schedule.hour, housekeeping_schedule.minute, housekeeping_schedule.second, self.async_nightly_tasks 

170 ) 

171 else: 

172 _LOGGER.info( 

173 "SUPERNOTIFY Housekeeping disabled. Storage must be manually managed if using attachments or image snapshots" 

174 ) 

175 

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

177 

178 async def async_shutdown(self, event: Event) -> None: 

179 _LOGGER.info("SUPERNOTIFY Shutting down, %s (%s)", event.event_type, event.time_fired) 

180 self.shutdown() 

181 

182 def shutdown(self) -> None: 

183 self.context.hass_api.disconnect() 

184 _LOGGER.info("SUPERNOTIFY Shut down") 

185 

186 @property 

187 def counter_sensors(self) -> list[SupernotifyCounterSensor]: 

188 return [self.notifications_sensor, self.failures_sensor] 

189 

190 @property 

191 def sent(self) -> int: 

192 return self.notifications_sensor.count 

193 

194 @property 

195 def failures(self) -> int: 

196 return self.failures_sensor.count 

197 

198 async def async_send_message( 

199 self, 

200 message: str = "", 

201 title: str | None = None, 

202 target: list[str] | str | dict[str, Any] | None = None, 

203 context: HAContext | None = None, 

204 **kwargs: Any, 

205 ) -> Notification | None: 

206 """Send a message via chosen transport, returning the notification, if one could be made""" 

207 data = kwargs.get(ATTR_DATA, {}) 

208 notification = None 

209 _LOGGER.debug("SUPERNOTIFY Message: %s, target: %s, data: %s", message, target, data) 

210 

211 if context is None: 

212 # only reachable when async_send_message is invoked directly rather than via a 

213 # registered action (e.g. SuperNotificationService._async_notify_message_service, or 

214 # supernotify.notify in async_setup_supplemental_actions) - without this fallback, 

215 # downstream service calls for this notification would each get their own 

216 # unrelated Context, leaving them unlinked in the logbook/recorder 

217 _LOGGER.debug("SUPERNOTIFY No context supplied, generating new one") 

218 context = HAContext() 

219 

220 try: 

221 notification = Notification(self.context, message, title, target, action_data=data, ha_context=context) 

222 await notification.initialize() 

223 if await notification.deliver(): 

224 self.notifications_sensor.increment() 

225 elif notification.failed: 

226 _LOGGER.error("SUPERNOTIFY Failed to deliver %s, error count %s", notification.id, notification.error_count) 

227 else: 

228 if notification.delivered == 0: 

229 codes: list[SuppressionReason] = notification._skip_reasons 

230 reason: str = ",".join(str(code) for code in codes) 

231 problem: bool = codes != [SuppressionReason.DUPE] 

232 else: 

233 problem = True 

234 reason = "No delivery envelopes generated" 

235 if problem: 

236 _LOGGER.warning("SUPERNOTIFY No deliveries made for %s: %s", notification.id, reason) 

237 else: 

238 _LOGGER.debug("SUPERNOTIFY Deliveries suppressed for %s: %s", notification.id, reason) 

239 

240 except Exception as err: 

241 # fault barrier of last resort, integration failures should be caught within envelope delivery 

242 _LOGGER.exception("SUPERNOTIFY Failed to send message %s", message) 

243 self.failures_sensor.increment() 

244 if notification is not None: 

245 notification._delivery_error = format_exception(err) 

246 

247 if notification is None: 

248 _LOGGER.warning("SUPERNOTIFY NULL Notification, %s", message) 

249 else: 

250 self.last_notification = notification 

251 await self.context.archive.archive(notification) 

252 _LOGGER.debug( 

253 "SUPERNOTIFY %s deliveries, %s failed, %s skipped, %s suppressed", 

254 notification.delivered, 

255 notification.failed, 

256 notification.skipped, 

257 notification.suppressed, 

258 ) 

259 if notification.uncategorized_targets: 

260 # raised only now, at the very end - every target that could be delivered 

261 # already has been, so one uncategorized target must never get in the way 

262 # of the rest of the notification going out 

263 raise UncategorizedTargetError(notification.delivered, notification.uncategorized_targets) 

264 return notification 

265 

266 async def async_dry_run( 

267 self, 

268 message: str = "", 

269 title: str | None = None, 

270 target: list[str] | str | dict[str, Any] | None = None, 

271 data: dict[str, Any] | None = None, 

272 ) -> dict[str, Any]: 

273 """Which deliveries a notification would use right now, and who it would reach, without sending it""" 

274 notification = Notification(self.context, message, title, target, action_data=data) 

275 await notification.initialize() 

276 return notification.plan() 

277 

278 @callback 

279 def refresh_entities(self) -> None: 

280 """Re-publish the current state of every entity SuperNotify provides. 

281 

282 Entities are only refreshed once added to Home Assistant, so this is a safe no-op for any 

283 whose platform hasn't loaded (e.g. in tests that build SupernotifyEngine directly without 

284 a config entry). Must run in the event loop, as it writes entity state. 

285 """ 

286 self.notifications_sensor.refresh() 

287 self.failures_sensor.refresh() 

288 self.context.scenario_registry.async_refresh_scenario_states() 

289 for entity in self.context.people_registry.recipient_entities(): 

290 entity.async_write_ha_state() 

291 for legacy_entity in self.context.delivery_registry.legacy_entities(): 

292 legacy_entity.async_write_ha_state() 

293 for switch in self.override_switches.values(): 

294 switch.async_write_ha_state() 

295 

296 def _overridables(self, kind: str) -> Iterable[Overridable]: 

297 overridables: dict[str, Iterable[Overridable]] = { 

298 OVERRIDE_KIND_SCENARIO: self.context.scenario_registry.scenarios.values(), 

299 OVERRIDE_KIND_RECIPIENT: self.context.people_registry.people.values(), 

300 OVERRIDE_KIND_DELIVERY: self.context.delivery_registry.deliveries.values(), 

301 OVERRIDE_KIND_TRANSPORT: self.context.delivery_registry.transports.values(), 

302 } 

303 return overridables[kind] 

304 

305 @callback 

306 def reset_overrides(self, kinds: Iterable[str] = OVERRIDE_KINDS) -> dict[str, list[str]]: 

307 """Put everything switched on or off at runtime back to its configured enabled state, 

308 returning the names reset for each kind. 

309 

310 Walks the scenarios, recipients, deliveries and transports themselves rather than their 

311 switches, so one whose switch is disabled in the entity registry is reset too. 

312 """ 

313 reset: dict[str, list[str]] = {} 

314 for kind in kinds: 

315 names = reset[kind] = [] 

316 for item in self._overridables(kind): 

317 if item.enabled == item.config_enabled: 

318 continue 

319 switch = self.override_switches.get(f"{kind}_{item.name}") 

320 if switch is not None: 

321 switch.async_set_enabled(item.config_enabled) 

322 else: 

323 item.enabled = item.config_enabled 

324 self._async_refresh_related(kind, item.name) 

325 names.append(item.name) 

326 return reset 

327 

328 @callback 

329 def _async_refresh_related(self, kind: str, name: str) -> None: 

330 """Re-publish the binary_sensor following the enabled flag of something with no switch 

331 to do it - the same as that switch's own _refresh_related().""" 

332 if kind == OVERRIDE_KIND_SCENARIO: 

333 self.context.scenario_registry.async_refresh_entity(name) 

334 elif kind == OVERRIDE_KIND_RECIPIENT: 

335 self.context.people_registry.async_refresh_entity(name) 

336 else: 

337 self.context.delivery_registry.async_refresh_entity(f"{kind}_{name}") 

338 

339 def enquire_implicit_deliveries(self) -> dict[str, Any]: 

340 v: dict[str, list[str]] = {} 

341 for t in self.context.delivery_registry.transports: 

342 for d in self.context.delivery_registry.implicit_deliveries: 

343 if d.transport.name == t: 

344 v.setdefault(t, []) 

345 v[t].append(d.name) 

346 return v 

347 

348 def enquire_deliveries_by_scenario(self) -> dict[str, dict[str, list[str]]]: 

349 return { 

350 name: { 

351 "enabled": scenario.enabling_deliveries(), 

352 "disabled": scenario.disabling_deliveries(), 

353 "applies": scenario.relevant_deliveries(), 

354 } 

355 for name, scenario in self.context.scenario_registry.scenarios.items() 

356 if scenario.enabled 

357 } 

358 

359 async def enquire_occupancy(self) -> dict[str, list[dict[str, Any]]]: 

360 occupancy = self.context.people_registry.determine_occupancy() 

361 return {k: [v.as_dict() for v in vs] for k, vs in occupancy.items()} 

362 

363 async def enquire_active_scenarios(self) -> list[str]: 

364 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy() 

365 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None) 

366 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(cvars)] 

367 

368 async def trace_active_scenarios(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: 

369 occupiers: dict[str, list[Recipient]] = self.context.people_registry.determine_occupancy() 

370 cvars = ConditionVariables([], [], [], PRIORITY_MEDIUM, occupiers, None, None) 

371 

372 def safe_json(v: Any) -> Any: # ruff: ignore[any-type] 

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

374 

375 enabled = [] 

376 disabled = [] 

377 dcvars = asdict(cvars) 

378 for s in self.context.scenario_registry.scenarios.values(): 

379 if await s.trace(cvars): 

380 enabled.append(safe_json(s.attributes(include_trace=True))) 

381 else: 

382 disabled.append(safe_json(s.attributes(include_trace=True))) 

383 return enabled, disabled, dcvars 

384 

385 def enquire_scenarios(self) -> dict[str, dict[str, Any]]: 

386 return {s.name: s.attributes(include_condition=False) for s in self.context.scenario_registry.scenarios.values()} 

387 

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

389 return self.context.snoozer.export() 

390 

391 def clear_snoozes(self) -> int: 

392 return self.context.snoozer.clear() 

393 

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

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

396 

397 @callback 

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

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

400 

401 Example Action: 

402 event_type: mobile_app_notification_action 

403 data: 

404 foo: a 

405 origin: REMOTE 

406 time_fired: "2024-04-20T13:14:09.360708+00:00" 

407 context: 

408 id: 01HVXT93JGWEDW0KE57Z0X6Z1K 

409 parent_id: null 

410 user_id: a9dbae1a5abf33dbbad52ff82201bb17 

411 """ 

412 event_name = event.data.get(ATTR_ACTION) 

413 if event_name is None or not event_name.startswith("SUPERNOTIFY_"): 

414 return # event not intended for here 

415 self.context.snoozer.handle_command_event(event, self.context.people_registry.enabled_recipients()) 

416 

417 @callback 

418 async def async_nightly_tasks(self, now: dt.datetime) -> None: 

419 _LOGGER.info("SUPERNOTIFY Housekeeping starting as scheduled at %s", now) 

420 await self.context.archive.cleanup() 

421 self.context.snoozer.purge_snoozes() 

422 await self.context.media_storage.cleanup() 

423 _LOGGER.info("SUPERNOTIFY Housekeeping completed") 

424 

425 

426def build_supernotify_engine(hass: HomeAssistant, config: ConfigType) -> SupernotifyEngine: 

427 """Construct a SupernotifyEngine from a fully validated FULL_CONFIG_SCHEMA config dict. 

428 

429 Used by the config-entry setup (async_setup_entry in __init__.py), the sole owner of 

430 registering notify.supernotify. 

431 """ 

432 return SupernotifyEngine( 

433 hass, 

434 deliveries=config[CONF_DELIVERY], 

435 template_path=config[CONF_TEMPLATE_PATH], 

436 media_path=config[CONF_MEDIA_PATH], 

437 media_url_prefix=config.get(CONF_MEDIA_URL_PREFIX), 

438 archive=config[CONF_ARCHIVE], 

439 housekeeping=config[CONF_HOUSEKEEPING], 

440 mobile_discovery=config[CONF_MOBILE_DISCOVERY], 

441 recipients_discovery=config[CONF_RECIPIENTS_DISCOVERY], 

442 recipients=config[CONF_RECIPIENTS], 

443 mobile_actions=config[CONF_ACTION_GROUPS], 

444 scenarios=config[CONF_SCENARIOS], 

445 links=config[CONF_LINKS], 

446 transport_configs=config[CONF_TRANSPORTS], 

447 cameras=config[CONF_CAMERAS], 

448 dupe_check=config[CONF_DUPE_CHECK], 

449 snooze=config[CONF_SNOOZE], 

450 scenario_control=config.get(CONF_SCENARIO_CONTROL), 

451 delivery_control=config.get(CONF_DELIVERY_CONTROL), 

452 )