Coverage for custom_components/supernotify/snoozer.py: 97%

266 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +0000

1from __future__ import annotations 

2 

3import datetime as dt 

4import logging 

5from datetime import timedelta 

6from typing import TYPE_CHECKING, Any 

7 

8from homeassistant.util import dt as dt_util 

9 

10from . import DOMAIN 

11from .common import canonical_priority, spoken_name 

12from .const import ( 

13 ATTR_ACTION, 

14 ATTR_MOBILE_APP_ID, 

15 ATTR_PERSON_ID, 

16 ATTR_REPLY_TEXT, 

17 CONF_SNOOZE_TIME, 

18 PRIORITY_CRITICAL, 

19 PRIORITY_MEDIUM, 

20 PRIORITY_VALUES, 

21) 

22from .model import CommandType, GlobalTargetType, QualifiedTargetType, RecipientType, Target, TargetType 

23 

24if TYPE_CHECKING: 

25 from homeassistant.core import Event 

26 

27 from .delivery import Delivery 

28 from .engine import SupernotifyEngine 

29 from .hass_api import HomeAssistantAPI 

30 from .people import PeopleRegistry, Recipient 

31 

32_LOGGER = logging.getLogger(__name__) 

33 

34# what a snooze covers, by the name the AI tool and supernotify.snooze give it 

35SNOOZE_SCOPES: dict[str, TargetType] = { 

36 "everything": GlobalTargetType.EVERYTHING, 

37 "noncritical": GlobalTargetType.NONCRITICAL, 

38 "delivery": QualifiedTargetType.DELIVERY, 

39 "transport": QualifiedTargetType.TRANSPORT, 

40 "priority": QualifiedTargetType.PRIORITY, 

41 "camera": QualifiedTargetType.CAMERA, 

42 "tag": QualifiedTargetType.TAG, 

43} 

44 

45 

46def snooze_name_error( 

47 engine: SupernotifyEngine, scope: QualifiedTargetType, name: str | None, cmd: CommandType 

48) -> tuple[str, dict[str, str]] | None: 

49 """Why a snooze of this scope can't take this name, as an `exceptions` translation key and its 

50 placeholders, or None - shared by the AI tool and supernotify.snooze""" 

51 registry = engine.context.delivery_registry 

52 valid: list[str] | None = { 

53 QualifiedTargetType.DELIVERY: list(registry.deliveries), 

54 QualifiedTargetType.TRANSPORT: list(registry.transports), 

55 QualifiedTargetType.PRIORITY: list(PRIORITY_VALUES), 

56 }.get(scope) 

57 if not name: 

58 return "snooze_name_needed", {"scope": scope.lower()} 

59 if scope == QualifiedTargetType.PRIORITY: 

60 name = canonical_priority(name) 

61 if valid is not None and name not in valid: 

62 return "snooze_name_unknown", {"scope": scope.lower(), "name": name, "choices": ", ".join(valid)} 

63 if scope == QualifiedTargetType.CAMERA and not name.startswith("camera."): 

64 return "snooze_camera_not_entity", {} 

65 # a tag snooze can still be undone when what it named has gone 

66 if scope == QualifiedTargetType.TAG and cmd != CommandType.NORMAL and not engine.tag_matches(name): 

67 return "snooze_tag_unknown", {"name": name} 

68 return None 

69 

70 

71STORAGE_VERSION = 1 

72STORAGE_KEY = f"{DOMAIN}.snoozes" 

73 

74 

75class Snooze: 

76 target: str | list[str] | None 

77 target_type: TargetType 

78 snoozed_at: dt.datetime 

79 snooze_until: dt.datetime | None = None 

80 recipient_type: RecipientType 

81 recipient: str | None 

82 reason: str | None = None 

83 

84 def __init__( 

85 self, 

86 target_type: TargetType, 

87 recipient_type: RecipientType, 

88 target: str | list[str] | None = None, 

89 recipient: str | None = None, 

90 snooze_for: timedelta | None = None, 

91 reason: str | None = None, 

92 ) -> None: 

93 self.snoozed_at = dt_util.now() 

94 self.target = target 

95 self.target_type = target_type 

96 self.recipient_type: RecipientType = recipient_type 

97 self.recipient = recipient 

98 self.reason = reason 

99 self.snooze_until = None 

100 if snooze_for: 

101 self.snooze_until = self.snoozed_at + snooze_for 

102 

103 def std_recipient(self) -> str | None: 

104 return self.recipient if self.recipient_type == RecipientType.USER else RecipientType.EVERYONE 

105 

106 def short_key(self) -> str: 

107 # only one GLOBAL can be active at a time 

108 target = "GLOBAL" if self.target_type in GlobalTargetType else f"{self.target_type}_{self.target}" 

109 return f"{target}_{self.std_recipient()}" 

110 

111 def __eq__(self, other: object) -> bool: 

112 """Check if two snoozes for the same thing""" 

113 if not isinstance(other, Snooze): 

114 return False 

115 return self.short_key() == other.short_key() 

116 

117 def __repr__(self) -> str: 

118 """Return a string representation of the object.""" 

119 target = "GLOBAL" if self.target_type in GlobalTargetType else f"{self.target_type}_{self.target}" 

120 return f"Snooze({target}, {self.std_recipient()})" 

121 

122 def active(self) -> bool: 

123 return self.snooze_until is None or self.snooze_until > dt_util.now() 

124 

125 def export(self) -> dict[str, Any]: 

126 return { 

127 "target_type": self.target_type, 

128 "target": self.target, 

129 "recipient_type": self.recipient_type, 

130 "recipient": self.recipient, 

131 "reason": self.reason, 

132 "snoozed_at": dt_util.as_local(self.snoozed_at).strftime("%H:%M:%S") if self.snoozed_at else None, 

133 "snooze_until": dt_util.as_local(self.snooze_until).strftime("%H:%M:%S") if self.snooze_until else None, 

134 } 

135 

136 def to_storage_dict(self) -> dict[str, Any]: 

137 """Full-fidelity serialization for persistence (unlike export(), which is a display 

138 summary that loses the date part of timestamps).""" 

139 return { 

140 "target_type_class": "GlobalTargetType" 

141 if isinstance(self.target_type, GlobalTargetType) 

142 else "QualifiedTargetType", 

143 "target_type": str(self.target_type), 

144 "target": self.target, 

145 "recipient_type": str(self.recipient_type), 

146 "recipient": self.recipient, 

147 "reason": self.reason, 

148 "snoozed_at": self.snoozed_at.isoformat() if self.snoozed_at else None, 

149 "snooze_until": self.snooze_until.isoformat() if self.snooze_until else None, 

150 } 

151 

152 @classmethod 

153 def from_storage_dict(cls, data: dict[str, Any]) -> Snooze | None: 

154 try: 

155 target_type_cls = GlobalTargetType if data["target_type_class"] == "GlobalTargetType" else QualifiedTargetType 

156 snooze = cls( 

157 target_type_cls(data["target_type"]), 

158 RecipientType(data["recipient_type"]), 

159 data.get("target"), 

160 data.get("recipient"), 

161 reason=data.get("reason"), 

162 ) 

163 if data.get("snoozed_at"): 

164 snoozed_at = dt_util.parse_datetime(data["snoozed_at"]) 

165 if snoozed_at: 

166 snooze.snoozed_at = snoozed_at 

167 snooze.snooze_until = dt_util.parse_datetime(data["snooze_until"]) if data.get("snooze_until") else None 

168 except Exception as e: 

169 _LOGGER.warning("SUPERNOTIFY Discarding invalid persisted snooze %s: %s", data, e) 

170 return None 

171 else: 

172 return snooze 

173 

174 

175class Snoozer: 

176 """Manage snoozing""" 

177 

178 def __init__(self, config: dict[str, Any] | None = None, people_registry: PeopleRegistry | None = None) -> None: 

179 self.snoozes: dict[str, Snooze] = {} 

180 self.people_registry: PeopleRegistry | None = people_registry 

181 self.config: dict[str, Any] = config or {} 

182 self.snooze_period = timedelta(seconds=self.config.get(CONF_SNOOZE_TIME, 60 * 60)) 

183 self.hass_api: HomeAssistantAPI | None = None 

184 

185 async def initialize(self, hass_api: HomeAssistantAPI) -> None: 

186 """Restore any snoozes persisted from a previous run - HA restarts and reloads no 

187 longer silently lose active snoozes/silences. Expired ones are dropped on restore.""" 

188 self.hass_api = hass_api 

189 stored: list[dict[str, Any]] | None = await hass_api.load_storage(STORAGE_KEY, STORAGE_VERSION) 

190 if not stored: 

191 return 

192 restored = 0 

193 for entry in stored: 

194 snooze: Snooze | None = Snooze.from_storage_dict(entry) 

195 if snooze and snooze.active(): 

196 self.snoozes[snooze.short_key()] = snooze 

197 restored += 1 

198 if restored: 

199 _LOGGER.info("SUPERNOTIFY Restored %s snooze(s) from storage", restored) 

200 

201 def _persist(self) -> None: 

202 if self.hass_api is not None: 

203 self.hass_api.save_storage(STORAGE_KEY, [s.to_storage_dict() for s in self.snoozes.values()], STORAGE_VERSION) 

204 

205 def handle_command_event(self, event: Event, people: list[Recipient] | None = None) -> None: 

206 people = people or [] 

207 try: 

208 cmd: CommandType 

209 target_type: TargetType | None = None 

210 target: str | None = None 

211 snooze_for: timedelta = self.snooze_period 

212 recipient_type: RecipientType | None = None 

213 event_name: str | None = event.data.get(ATTR_ACTION) 

214 

215 if not event_name: 

216 _LOGGER.warning( 

217 "SUPERNOTIFY Invalid Mobile Action: %s, %s, %s, %s", 

218 event.origin, 

219 event.time_fired, 

220 event.data, 

221 event.context, 

222 ) 

223 return 

224 

225 _LOGGER.debug( 

226 "SUPERNOTIFY Mobile Action: %s, %s, %s, %s", event.origin, event.time_fired, event.data, event.context 

227 ) 

228 event_parts: list[str] = event_name.split("_") 

229 if len(event_parts) < 4: 

230 _LOGGER.warning("SUPERNOTIFY Malformed mobile event action %s", event_name) 

231 return 

232 cmd = CommandType[event_parts[1]] 

233 recipient_type = RecipientType[event_parts[2]] 

234 # a text input reply carries the minutes, so the whole remainder is the target; 

235 # otherwise a trailing number is minutes, which is ambiguous for names ending in _<digits> 

236 reply_text: str | None = event.data.get(ATTR_REPLY_TEXT) 

237 reply_minutes: int | None = int(reply_text) if reply_text and reply_text.strip().isdigit() else None 

238 if event_parts[3] in QualifiedTargetType and len(event_parts) > 4: 

239 target_type = QualifiedTargetType[event_parts[3]] 

240 target_parts: list[str] = event_parts[4:] 

241 if reply_text is None and len(target_parts) > 1 and target_parts[-1].isdigit(): 

242 reply_minutes = int(target_parts[-1]) 

243 target_parts = target_parts[:-1] 

244 target = "_".join(target_parts) 

245 elif event_parts[3] in GlobalTargetType and len(event_parts) >= 4: 

246 target_type = GlobalTargetType[event_parts[3]] 

247 if reply_text is None and len(event_parts) == 5: 

248 reply_minutes = int(event_parts[-1]) 

249 if reply_minutes: 

250 snooze_for = timedelta(minutes=reply_minutes) 

251 

252 if cmd is None or target_type is None or recipient_type is None: 

253 _LOGGER.warning("SUPERNOTIFY Invalid mobile event name %s", event_name) 

254 return 

255 

256 except KeyError as ke: 

257 _LOGGER.warning("SUPERNOTIFY Unknown enum in event %s: %s", event, ke) 

258 return 

259 except Exception as e: 

260 _LOGGER.warning("SUPERNOTIFY Unable to analyze event %s: %s", event, e) 

261 return 

262 

263 try: 

264 recipient: str | None = None 

265 if recipient_type == RecipientType.USER: 

266 target_people: list[str] = [ 

267 p.entity_id 

268 for p in people 

269 if p.user_id == event.context.user_id and event.context.user_id is not None and p.entity_id 

270 ] 

271 if target_people: 

272 recipient = target_people[0] 

273 _LOGGER.debug("SUPERNOTIFY Mobile action from %s mapped to %s", event.context.user_id, recipient) 

274 else: 

275 _LOGGER.warning("SUPERNOTIFY Unable to find person for action from %s", event.context.user_id) 

276 return 

277 

278 self.register_snooze(cmd, target_type, target, recipient_type, recipient, snooze_for) 

279 

280 except Exception as e: 

281 _LOGGER.warning("SUPERNOTIFY Unable to handle event %s: %s", event, e) 

282 

283 def register_snooze( 

284 self, 

285 cmd: CommandType, 

286 target_type: TargetType, 

287 target: str | None, 

288 recipient_type: RecipientType, 

289 recipient: str | None, 

290 snooze_for: timedelta | None, 

291 reason: str = "User command", 

292 ) -> None: 

293 if target_type == QualifiedTargetType.TAG and target: 

294 # so the same tag however it was said or typed is one snooze, and can be resumed 

295 target = spoken_name(target) 

296 if target_type == QualifiedTargetType.PRIORITY: 

297 target = canonical_priority(target) 

298 if cmd == CommandType.SNOOZE: 

299 snooze = Snooze(target_type, recipient_type, target, recipient, snooze_for, reason=reason) 

300 self.snoozes[snooze.short_key()] = snooze 

301 self._persist() 

302 elif cmd == CommandType.SILENCE: 

303 snooze = Snooze(target_type, recipient_type, target, recipient, reason=reason) 

304 self.snoozes[snooze.short_key()] = snooze 

305 self._persist() 

306 elif cmd == CommandType.NORMAL: 

307 anti_snooze = Snooze(target_type, recipient_type, target, recipient) 

308 to_del: list[str] = [k for k, v in self.snoozes.items() if v.short_key() == anti_snooze.short_key()] 

309 for k in to_del: 

310 del self.snoozes[k] 

311 if to_del: 

312 self._persist() 

313 else: 

314 _LOGGER.warning( # type: ignore 

315 "SUPERNOTIFY Invalid mobile cmd %s (target_type: %s, target: %s, recipient_type: %s)", 

316 cmd, 

317 target_type, 

318 target, 

319 recipient_type, 

320 ) 

321 

322 def purge_snoozes(self) -> None: 

323 to_del: list[str] = [k for k, v in self.snoozes.items() if not v.active() or self._mobile_app_gone(v)] 

324 for k in to_del: 

325 del self.snoozes[k] 

326 if to_del: 

327 self._persist() 

328 

329 def _mobile_app_gone(self, snooze: Snooze) -> bool: 

330 """A snooze on a mobile device whose notify action no longer exists, e.g. re-paired under a new name""" 

331 return ( 

332 snooze.target_type == QualifiedTargetType.MOBILE 

333 and isinstance(snooze.target, str) 

334 and self.hass_api is not None 

335 and not self.hass_api.mobile_app_action_exists(snooze.target) 

336 ) 

337 

338 def clear(self) -> int: 

339 cleared: int = len(self.snoozes) 

340 self.snoozes.clear() 

341 if cleared: 

342 self._persist() 

343 return cleared 

344 

345 def export(self) -> list[dict[str, Any]]: 

346 """Active snoozes only: expired ones stay in memory until the nightly purge, and their 

347 time-only snooze_until would otherwise read as a snooze still to come.""" 

348 return [s.export() for s in self.snoozes.values() if s.active()] 

349 

350 def current_snoozes(self, priority: str, delivery: Delivery) -> list[Snooze]: 

351 inscope_snoozes: list[Snooze] = [] 

352 

353 for snooze in self.snoozes.values(): 

354 if snooze.active(): 

355 match snooze.target_type: 

356 case GlobalTargetType.EVERYTHING: 

357 inscope_snoozes.append(snooze) 

358 case GlobalTargetType.NONCRITICAL: 

359 if priority != PRIORITY_CRITICAL: 

360 inscope_snoozes.append(snooze) 

361 case QualifiedTargetType.DELIVERY: 

362 if snooze.target == delivery.name: 

363 inscope_snoozes.append(snooze) 

364 case QualifiedTargetType.PRIORITY: 

365 if snooze.target == priority: 

366 inscope_snoozes.append(snooze) 

367 case QualifiedTargetType.MOBILE: 

368 inscope_snoozes.append(snooze) 

369 case QualifiedTargetType.TRANSPORT: 

370 if snooze.target == delivery.transport.name: 

371 inscope_snoozes.append(snooze) 

372 case QualifiedTargetType.CAMERA | QualifiedTargetType.TAG: 

373 inscope_snoozes.append(snooze) 

374 case _: 

375 _LOGGER.warning("SUPERNOTIFY Unhandled target type %s", snooze.target_type) 

376 

377 return inscope_snoozes 

378 

379 def has_tag_snooze(self) -> bool: 

380 return any(s.active() and s.target_type == QualifiedTargetType.TAG for s in self.snoozes.values()) 

381 

382 def is_global_snooze(self, priority: str = PRIORITY_MEDIUM) -> bool: 

383 for snooze in self.snoozes.values(): 

384 if snooze.active() and snooze.recipient_type == RecipientType.EVERYONE: 

385 match snooze.target_type: 

386 case GlobalTargetType.EVERYTHING: 

387 return True 

388 case GlobalTargetType.NONCRITICAL: 

389 if priority != PRIORITY_CRITICAL: 

390 return True 

391 

392 return False 

393 

394 def is_delivery_snoozed( 

395 self, 

396 priority: str, 

397 delivery: Delivery, 

398 camera_entity_id: str | None = None, 

399 tags: set[str] | None = None, 

400 ) -> bool: 

401 """Everyone-scoped delivery, transport, priority, camera or tag snoozes stop the whole delivery""" 

402 for snooze in self.current_snoozes(priority, delivery): 

403 if snooze.recipient_type != RecipientType.EVERYONE: 

404 continue 

405 if snooze.target_type in ( 

406 QualifiedTargetType.DELIVERY, 

407 QualifiedTargetType.TRANSPORT, 

408 QualifiedTargetType.PRIORITY, 

409 ): 

410 return True 

411 if _snoozes_subject(snooze, camera_entity_id, tags): 

412 return True 

413 return False 

414 

415 def filter_recipients( 

416 self, 

417 recipients: Target, 

418 priority: str, 

419 delivery: Delivery, 

420 camera_entity_id: str | None = None, 

421 tags: set[str] | None = None, 

422 ) -> Target: 

423 inscope_snoozes: list[Snooze] = self.current_snoozes(priority, delivery) 

424 for snooze in inscope_snoozes: 

425 # everyone-scoped snoozes are checked by is_global_snooze and is_delivery_snoozed 

426 if snooze.recipient_type == RecipientType.USER and ( 

427 (snooze.target_type == QualifiedTargetType.DELIVERY and snooze.target == delivery.name) 

428 or (snooze.target_type == QualifiedTargetType.TRANSPORT and snooze.target == delivery.transport.name) 

429 or ( 

430 snooze.target_type == QualifiedTargetType.PRIORITY 

431 and (snooze.target == priority or (isinstance(snooze.target, list) and priority in snooze.target)) 

432 ) 

433 or _snoozes_subject(snooze, camera_entity_id, tags) 

434 or snooze.target_type == GlobalTargetType.EVERYTHING 

435 or (snooze.target_type == GlobalTargetType.NONCRITICAL and priority != PRIORITY_CRITICAL) 

436 ): 

437 recipients_to_remove: list[str] = [] 

438 for recipient in recipients.person_ids: 

439 if recipient == snooze.recipient: 

440 recipients_to_remove.append(recipient) 

441 _LOGGER.info("SUPERNOTIFY Snoozing %s", snooze.recipient) 

442 

443 recipients.remove(ATTR_PERSON_ID, recipients_to_remove) 

444 

445 return self.filter_mobile_app_ids(recipients, priority, delivery) 

446 

447 def filter_mobile_app_ids(self, target: Target, priority: str, delivery: Delivery) -> Target: 

448 """Drop snoozed mobile devices, leaving everything else - person_ids included - in place 

449 

450 Applied to the action's own targets, and again to each recipient's addresses as the 

451 person is resolved, since that's the first point a person's mobile devices are known 

452 """ 

453 snoozed: set[str] = { 

454 str(s.target) 

455 for s in self.current_snoozes(priority, delivery) 

456 if s.target_type == QualifiedTargetType.MOBILE and s.target 

457 } 

458 if not snoozed: 

459 return target 

460 to_remove: list[str] = [m for m in target.mobile_app_ids if m.removeprefix("notify.") in snoozed] 

461 if not to_remove: 

462 return target 

463 _LOGGER.debug("SUPERNOTIFY Snoozing mobile devices %s", to_remove) 

464 return target - Target({ATTR_MOBILE_APP_ID: to_remove}) 

465 

466 

467def _snoozes_subject(snooze: Snooze, camera_entity_id: str | None, tags: set[str] | None) -> bool: 

468 """Whether a camera or tag snooze is about this notification - tags are as spoken_name() has them""" 

469 match snooze.target_type: 

470 case QualifiedTargetType.CAMERA: 

471 return camera_entity_id is not None and snooze.target == camera_entity_id 

472 case QualifiedTargetType.TAG: 

473 return isinstance(snooze.target, str) and spoken_name(snooze.target) in (tags or set()) 

474 return False