Coverage for custom_components/camera_autonotify/tracker.py: 100%

219 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:32 +0000

1"""What to notify for the reviews of one camera or zone, as Frigate reports them over MQTT 

2 

3Pure logic, with no Home Assistant in it: the time is passed in, and anything to happen later is handed back as 

4a `Wait` for the caller to time, and then call `on_timer` for. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10from collections.abc import Mapping 

11from dataclasses import dataclass, field, fields 

12from enum import StrEnum 

13from typing import Any 

14 

15from homeassistant.util import slugify 

16 

17SEVERITY_ALERT = "alert" 

18SEVERITY_DETECTION = "detection" 

19TYPE_NEW = "new" 

20TYPE_UPDATE = "update" 

21TYPE_END = "end" 

22# a review that never ends, from a lost MQTT message or a restart of Frigate, is forgotten after this long 

23STALE_AFTER = 3600 

24 

25 

26def _slugs(zones: tuple[str, ...] | set[str]) -> set[str]: 

27 return {slugify(zone) for zone in zones} 

28 

29 

30class Reason(StrEnum): 

31 """Why a notification is sent""" 

32 

33 NEW = "new" 

34 LABEL = "label" 

35 ZONE = "zone" 

36 SUB_LABEL = "sub_label" 

37 SEVERITY = "severity" 

38 FINAL = "final" 

39 

40 

41@dataclass(frozen=True) 

42class Settings: 

43 """The filters and timing of the Frigate blueprint, with its defaults where it has them""" 

44 

45 severities: frozenset[str] = frozenset({SEVERITY_ALERT}) 

46 # any, where empty 

47 labels: frozenset[str] = frozenset() 

48 zones: tuple[str, ...] = () 

49 # every zone chosen, rather than any of them 

50 zone_multi: bool = False 

51 # and entered in the order chosen 

52 zone_order: bool = False 

53 # seconds after a review is notified before another review is 

54 cooldown: float = 30 

55 # seconds held back before the first notification of a review, for a better snapshot 

56 initial_delay: float = 0 

57 # a last notification once the review has ended, after this many seconds 

58 final_update: bool = False 

59 final_delay: float = 0 

60 # seconds from the first notification of a review after which nothing more is sent for it 

61 timeout: float = 600 

62 # only the first notification of a review makes a sound 

63 alert_once: bool = True 

64 # told again when a sub label, such as a face or plate, is recognised 

65 update_sub_label: bool = True 

66 

67 @classmethod 

68 def from_options(cls, options: Mapping[str, Any]) -> Settings: 

69 """From a camera's options, which are named as these, and only kept where they differ from the default""" 

70 given = {f.name: options[f.name] for f in fields(cls) if f.name in options} 

71 for name in ("severities", "labels"): 

72 if name in given: 

73 given[name] = frozenset(given[name]) 

74 if "zones" in given: 

75 given["zones"] = tuple(given["zones"]) 

76 return cls(**given) 

77 

78 

79@dataclass(frozen=True) 

80class Review: 

81 """A review as Frigate reports it, in the `after` of its MQTT message""" 

82 

83 id: str 

84 camera: str 

85 severity: str 

86 objects: tuple[str, ...] 

87 sub_labels: tuple[str, ...] 

88 # as written in the Frigate config, in the order entered 

89 zones: tuple[str, ...] 

90 detections: tuple[str, ...] 

91 ended: bool 

92 

93 @classmethod 

94 def from_payload(cls, after: dict[str, Any]) -> Review: 

95 data = after.get("data") or {} 

96 # audio, such as a scream or a bark, is as much what was seen as an object is 

97 objects = [*data.get("objects", []), *data.get("audio", [])] 

98 sub_labels = [label[0] if isinstance(label, list) else label for label in data.get("sub_labels") or []] 

99 return cls( 

100 id=after["id"], 

101 camera=after["camera"], 

102 severity=after.get("severity") or SEVERITY_DETECTION, 

103 objects=tuple(dict.fromkeys(objects)), 

104 sub_labels=tuple(dict.fromkeys(str(label) for label in sub_labels if label)), 

105 zones=tuple(dict.fromkeys(data.get("zones") or [])), 

106 detections=tuple(data.get("detections") or []), 

107 ended=after.get("end_time") is not None, 

108 ) 

109 

110 @property 

111 def label(self) -> str: 

112 """What was seen, as said in a notification, with whoever or whatever was recognised""" 

113 text = " and ".join(obj.replace("_", " ").capitalize() for obj in self.objects) or "Something" 

114 return f"{text} ({', '.join(self.sub_labels)})" if self.sub_labels else text 

115 

116 

117@dataclass(frozen=True) 

118class Decision: 

119 """A notification to send, with the review as it then is""" 

120 

121 review: Review 

122 reason: Reason 

123 # the notifications sent for the review before this one 

124 previous: int 

125 silent: bool 

126 

127 @property 

128 def is_update(self) -> bool: 

129 return self.previous > 0 

130 

131 @property 

132 def is_final(self) -> bool: 

133 return self.reason is Reason.FINAL 

134 

135 @property 

136 def detection_id(self) -> str | None: 

137 """The detection whose snapshot shows the review, the first, which is what set it off""" 

138 return self.review.detections[0] if self.review.detections else None 

139 

140 

141@dataclass(frozen=True) 

142class Wait: 

143 """A notification held back, to be looked at again with `on_timer` at `at`""" 

144 

145 review_id: str 

146 at: float 

147 reason: Reason 

148 

149 

150@dataclass 

151class _Tracked: 

152 review: Review 

153 first_seen: float 

154 # when the first notification went, or is due 

155 notified_at: float | None = None 

156 sent: int = 0 

157 # as last notified, so an update is only for what's new since 

158 objects: set[str] = field(default_factory=set) 

159 zones: set[str] = field(default_factory=set) 

160 sub_labels: set[str] = field(default_factory=set) 

161 severity: str = "" 

162 # held back, the first notification by the initial delay, or the last by the final delay 

163 waiting: Reason | None = None 

164 # passed over for the cooldown, so none of its updates are sent either 

165 skipped: bool = False 

166 

167 

168class ReviewTracker: 

169 """The reviews of a camera, or of a zone on any camera, and the notifications each should give""" 

170 

171 def __init__(self, settings: Settings, camera: str | None = None, zone: str | None = None) -> None: 

172 self.settings = settings 

173 self.camera = camera 

174 # a zone is matched by its slug, as its device has it, since reviews give it as written in the Frigate config 

175 self.zone = slugify(zone) if zone else None 

176 self._reviews: dict[str, _Tracked] = {} 

177 self._last_new: float | None = None 

178 

179 def on_message(self, payload: str | bytes | bytearray | dict[str, Any], now: float) -> Decision | Wait | None: 

180 """A message from `{topic_prefix}/reviews`""" 

181 message = json.loads(payload) if isinstance(payload, (str, bytes, bytearray)) else payload 

182 after = message.get("after") 

183 if not isinstance(after, dict) or not after.get("id") or not after.get("camera"): 

184 return None 

185 review = Review.from_payload(after) 

186 if not self._ours(review): 

187 return None 

188 self._forget_stale(now) 

189 tracked = self._reviews.get(review.id) 

190 if tracked is None: 

191 if message.get("type") == TYPE_END and not self._wanted(review): 

192 return None 

193 tracked = self._reviews[review.id] = _Tracked(review, first_seen=now) 

194 tracked.review = review 

195 if tracked.skipped: 

196 self._drop_if_ended(tracked) 

197 return None 

198 if tracked.notified_at is None: 

199 return self._first(tracked, now) 

200 if tracked.waiting is not None: 

201 # held back, and sent as it is once it's due, with whatever has changed 

202 return None 

203 if now - tracked.notified_at > self.settings.timeout: 

204 del self._reviews[review.id] 

205 return None 

206 if review.ended: 

207 return self._ending(tracked, now) 

208 return self._update(tracked) 

209 

210 def on_timer(self, review_id: str, now: float) -> Decision | Wait | None: 

211 """A notification held back is due""" 

212 tracked = self._reviews.get(review_id) 

213 if tracked is None or tracked.waiting is None: 

214 return None 

215 reason, tracked.waiting = tracked.waiting, None 

216 if reason is Reason.FINAL: 

217 del self._reviews[review_id] 

218 return self._decide(tracked, Reason.FINAL) 

219 return self._first_decision(tracked) 

220 

221 @property 

222 def tracked(self) -> list[str]: 

223 return list(self._reviews) 

224 

225 def _ours(self, review: Review) -> bool: 

226 if self.zone is not None: 

227 return self.zone in {slugify(zone) for zone in review.zones} 

228 return review.camera == self.camera 

229 

230 def _wanted(self, review: Review) -> bool: 

231 settings = self.settings 

232 if review.severity not in settings.severities: 

233 return False 

234 if settings.labels and not settings.labels & set(review.objects): 

235 return False 

236 if settings.zones: 

237 chosen = [slugify(zone) for zone in settings.zones] 

238 entered = [slugify(zone) for zone in review.zones] 

239 matched = [zone for zone in chosen if zone in entered] 

240 if not matched or (settings.zone_multi and len(matched) < len(chosen)): 

241 return False 

242 # Frigate lists the zones of a review in the order they were entered 

243 positions = [entered.index(zone) for zone in matched] 

244 if settings.zone_order and positions != sorted(positions): 

245 return False 

246 return True 

247 

248 def _first(self, tracked: _Tracked, now: float) -> Decision | Wait | None: 

249 if not self._wanted(tracked.review): 

250 # may yet be, as the review goes on 

251 self._drop_if_ended(tracked) 

252 return None 

253 if self._last_new is not None and now - self._last_new < self.settings.cooldown: 

254 tracked.skipped = True 

255 self._drop_if_ended(tracked) 

256 return None 

257 self._last_new = tracked.notified_at = now 

258 if self.settings.initial_delay > 0: 

259 tracked.waiting = Reason.NEW 

260 return Wait(tracked.review.id, now + self.settings.initial_delay, Reason.NEW) 

261 return self._first_decision(tracked) 

262 

263 def _first_decision(self, tracked: _Tracked) -> Decision: 

264 decision = self._decide(tracked, Reason.NEW) 

265 if tracked.review.ended: 

266 # over before it was told at all, so told once, with nothing to follow 

267 del self._reviews[tracked.review.id] 

268 return decision 

269 

270 def _update(self, tracked: _Tracked) -> Decision | None: 

271 review = tracked.review 

272 settings = self.settings 

273 reason: Reason | None = None 

274 if review.severity == SEVERITY_ALERT and tracked.severity != SEVERITY_ALERT: 

275 reason = Reason.SEVERITY 

276 elif (new := set(review.objects) - tracked.objects) and (not settings.labels or new & settings.labels): 

277 reason = Reason.LABEL 

278 elif settings.update_sub_label and set(review.sub_labels) - tracked.sub_labels: 

279 reason = Reason.SUB_LABEL 

280 elif settings.zones and (_slugs(review.zones) - _slugs(tracked.zones)) & _slugs(settings.zones): 

281 reason = Reason.ZONE 

282 return self._decide(tracked, reason) if reason is not None else None 

283 

284 def _ending(self, tracked: _Tracked, now: float) -> Decision | Wait | None: 

285 if not self.settings.final_update: 

286 del self._reviews[tracked.review.id] 

287 return None 

288 if self.settings.final_delay > 0: 

289 tracked.waiting = Reason.FINAL 

290 return Wait(tracked.review.id, now + self.settings.final_delay, Reason.FINAL) 

291 del self._reviews[tracked.review.id] 

292 return self._decide(tracked, Reason.FINAL) 

293 

294 def _decide(self, tracked: _Tracked, reason: Reason) -> Decision: 

295 review = tracked.review 

296 decision = Decision(review, reason, tracked.sent, silent=tracked.sent > 0 and self.settings.alert_once) 

297 tracked.sent += 1 

298 tracked.objects = set(review.objects) 

299 tracked.zones = set(review.zones) 

300 tracked.sub_labels = set(review.sub_labels) 

301 tracked.severity = review.severity 

302 return decision 

303 

304 def _drop_if_ended(self, tracked: _Tracked) -> None: 

305 if tracked.review.ended: 

306 del self._reviews[tracked.review.id] 

307 

308 def _forget_stale(self, now: float) -> None: 

309 for review_id in [r for r, tracked in self._reviews.items() if now - tracked.first_seen > STALE_AFTER]: 

310 del self._reviews[review_id]