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

234 statements  

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

12 ATTR_ACTION, 

13 ATTR_MOBILE_APP_ID, 

14 ATTR_PERSON_ID, 

15 ATTR_REPLY_TEXT, 

16 CONF_SNOOZE_TIME, 

17 PRIORITY_CRITICAL, 

18 PRIORITY_MEDIUM, 

19) 

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

21 

22if TYPE_CHECKING: 

23 from homeassistant.core import Event 

24 

25 from .delivery import Delivery 

26 from .hass_api import HomeAssistantAPI 

27 from .people import PeopleRegistry, Recipient 

28 

29_LOGGER = logging.getLogger(__name__) 

30 

31STORAGE_VERSION = 1 

32STORAGE_KEY = f"{DOMAIN}.snoozes" 

33 

34 

35class Snooze: 

36 target: str | list[str] | None 

37 target_type: TargetType 

38 snoozed_at: dt.datetime 

39 snooze_until: dt.datetime | None = None 

40 recipient_type: RecipientType 

41 recipient: str | None 

42 reason: str | None = None 

43 

44 def __init__( 

45 self, 

46 target_type: TargetType, 

47 recipient_type: RecipientType, 

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

49 recipient: str | None = None, 

50 snooze_for: timedelta | None = None, 

51 reason: str | None = None, 

52 ) -> None: 

53 self.snoozed_at = dt_util.now() 

54 self.target = target 

55 self.target_type = target_type 

56 self.recipient_type: RecipientType = recipient_type 

57 self.recipient = recipient 

58 self.reason = reason 

59 self.snooze_until = None 

60 if snooze_for: 

61 self.snooze_until = self.snoozed_at + snooze_for 

62 

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

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

65 

66 def short_key(self) -> str: 

67 # only one GLOBAL can be active at a time 

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

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

70 

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

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

73 if not isinstance(other, Snooze): 

74 return False 

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

76 

77 def __repr__(self) -> str: 

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

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

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

81 

82 def active(self) -> bool: 

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

84 

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

86 return { 

87 "target_type": self.target_type, 

88 "target": self.target, 

89 "recipient_type": self.recipient_type, 

90 "recipient": self.recipient, 

91 "reason": self.reason, 

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

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

94 } 

95 

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

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

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

99 return { 

100 "target_type_class": "GlobalTargetType" 

101 if isinstance(self.target_type, GlobalTargetType) 

102 else "QualifiedTargetType", 

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

104 "target": self.target, 

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

106 "recipient": self.recipient, 

107 "reason": self.reason, 

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

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

110 } 

111 

112 @classmethod 

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

114 try: 

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

116 snooze = cls( 

117 target_type_cls(data["target_type"]), 

118 RecipientType(data["recipient_type"]), 

119 data.get("target"), 

120 data.get("recipient"), 

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

122 ) 

123 if data.get("snoozed_at"): 

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

125 if snoozed_at: 

126 snooze.snoozed_at = snoozed_at 

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

128 except Exception as e: 

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

130 return None 

131 else: 

132 return snooze 

133 

134 

135class Snoozer: 

136 """Manage snoozing""" 

137 

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

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

140 self.people_registry: PeopleRegistry | None = people_registry 

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

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

143 self.hass_api: HomeAssistantAPI | None = None 

144 

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

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

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

148 self.hass_api = hass_api 

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

150 if not stored: 

151 return 

152 restored = 0 

153 for entry in stored: 

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

155 if snooze and snooze.active(): 

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

157 restored += 1 

158 if restored: 

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

160 

161 def _persist(self) -> None: 

162 if self.hass_api is not None: 

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

164 

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

166 people = people or [] 

167 try: 

168 cmd: CommandType 

169 target_type: TargetType | None = None 

170 target: str | None = None 

171 snooze_for: timedelta = self.snooze_period 

172 recipient_type: RecipientType | None = None 

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

174 

175 if not event_name: 

176 _LOGGER.warning( 

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

178 event.origin, 

179 event.time_fired, 

180 event.data, 

181 event.context, 

182 ) 

183 return 

184 

185 _LOGGER.debug( 

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

187 ) 

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

189 if len(event_parts) < 4: 

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

191 return 

192 cmd = CommandType[event_parts[1]] 

193 recipient_type = RecipientType[event_parts[2]] 

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

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

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

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

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

199 target_type = QualifiedTargetType[event_parts[3]] 

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

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

202 reply_minutes = int(target_parts[-1]) 

203 target_parts = target_parts[:-1] 

204 target = "_".join(target_parts) 

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

206 target_type = GlobalTargetType[event_parts[3]] 

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

208 reply_minutes = int(event_parts[-1]) 

209 if reply_minutes: 

210 snooze_for = timedelta(minutes=reply_minutes) 

211 

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

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

214 return 

215 

216 except KeyError as ke: 

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

218 return 

219 except Exception as e: 

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

221 return 

222 

223 try: 

224 recipient: str | None = None 

225 if recipient_type == RecipientType.USER: 

226 target_people: list[str] = [ 

227 p.entity_id 

228 for p in people 

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

230 ] 

231 if target_people: 

232 recipient = target_people[0] 

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

234 else: 

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

236 return 

237 

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

239 

240 except Exception as e: 

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

242 

243 def register_snooze( 

244 self, 

245 cmd: CommandType, 

246 target_type: TargetType, 

247 target: str | None, 

248 recipient_type: RecipientType, 

249 recipient: str | None, 

250 snooze_for: timedelta | None, 

251 reason: str = "User command", 

252 ) -> None: 

253 if cmd == CommandType.SNOOZE: 

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

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

256 self._persist() 

257 elif cmd == CommandType.SILENCE: 

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

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

260 self._persist() 

261 elif cmd == CommandType.NORMAL: 

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

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

264 for k in to_del: 

265 del self.snoozes[k] 

266 if to_del: 

267 self._persist() 

268 else: 

269 _LOGGER.warning( # type: ignore 

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

271 cmd, 

272 target_type, 

273 target, 

274 recipient_type, 

275 ) 

276 

277 def purge_snoozes(self) -> None: 

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

279 for k in to_del: 

280 del self.snoozes[k] 

281 if to_del: 

282 self._persist() 

283 

284 def clear(self) -> int: 

285 cleared: int = len(self.snoozes) 

286 self.snoozes.clear() 

287 if cleared: 

288 self._persist() 

289 return cleared 

290 

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

292 return [s.export() for s in self.snoozes.values()] 

293 

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

295 inscope_snoozes: list[Snooze] = [] 

296 

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

298 if snooze.active(): 

299 match snooze.target_type: 

300 case GlobalTargetType.EVERYTHING: 

301 inscope_snoozes.append(snooze) 

302 case GlobalTargetType.NONCRITICAL: 

303 if priority != PRIORITY_CRITICAL: 

304 inscope_snoozes.append(snooze) 

305 case QualifiedTargetType.DELIVERY: 

306 if snooze.target == delivery.name: 

307 inscope_snoozes.append(snooze) 

308 case QualifiedTargetType.PRIORITY: 

309 if snooze.target == priority: 

310 inscope_snoozes.append(snooze) 

311 case QualifiedTargetType.MOBILE: 

312 inscope_snoozes.append(snooze) 

313 case QualifiedTargetType.TRANSPORT: 

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

315 inscope_snoozes.append(snooze) 

316 case QualifiedTargetType.CAMERA: 

317 inscope_snoozes.append(snooze) 

318 case _: 

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

320 

321 return inscope_snoozes 

322 

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

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

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

326 match snooze.target_type: 

327 case GlobalTargetType.EVERYTHING: 

328 return True 

329 case GlobalTargetType.NONCRITICAL: 

330 if priority != PRIORITY_CRITICAL: 

331 return True 

332 

333 return False 

334 

335 def is_delivery_snoozed(self, priority: str, delivery: Delivery, camera_entity_id: str | None = None) -> bool: 

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

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

338 if snooze.recipient_type != RecipientType.EVERYONE: 

339 continue 

340 if snooze.target_type in ( 

341 QualifiedTargetType.DELIVERY, 

342 QualifiedTargetType.TRANSPORT, 

343 QualifiedTargetType.PRIORITY, 

344 ): 

345 return True 

346 if snooze.target_type == QualifiedTargetType.CAMERA and camera_entity_id and snooze.target == camera_entity_id: 

347 return True 

348 return False 

349 

350 def filter_recipients( 

351 self, recipients: Target, priority: str, delivery: Delivery, camera_entity_id: str | None = None 

352 ) -> Target: 

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

354 for snooze in inscope_snoozes: 

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

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

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

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

359 or ( 

360 snooze.target_type == QualifiedTargetType.PRIORITY 

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

362 ) 

363 or ( 

364 snooze.target_type == QualifiedTargetType.CAMERA 

365 and camera_entity_id is not None 

366 and snooze.target == camera_entity_id 

367 ) 

368 or snooze.target_type == GlobalTargetType.EVERYTHING 

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

370 ): 

371 recipients_to_remove: list[str] = [] 

372 for recipient in recipients.person_ids: 

373 if recipient == snooze.recipient: 

374 recipients_to_remove.append(recipient) 

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

376 

377 recipients.remove(ATTR_PERSON_ID, recipients_to_remove) 

378 

379 if snooze.target_type == QualifiedTargetType.MOBILE: 

380 to_remove: list[str] = [] 

381 for recipient in recipients.mobile_app_ids: 

382 if recipient == snooze.target: 

383 _LOGGER.debug("SUPERNOTIFY Snoozing %s for %s", snooze.std_recipient(), snooze.target) 

384 to_remove.append(recipient) 

385 if to_remove: 

386 recipients.remove(ATTR_MOBILE_APP_ID, to_remove) 

387 return recipients