Coverage for custom_components/camera_autonotify/hub.py: 98%
93 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:32 +0000
1"""Reviews from Frigate over MQTT, one subscription for each topic prefix, shared by the entries that need it, and
2each entry's watcher, which times what its tracker holds back and sends what it decides"""
4from __future__ import annotations
6import logging
7import time
8from dataclasses import dataclass, field
9from typing import TYPE_CHECKING, Any
11from homeassistant.components import mqtt
12from homeassistant.const import CONF_TYPE
13from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
14from homeassistant.helpers.event import async_call_later
16from .const import CONF_SOURCE_NAME, DOMAIN, TYPE_ZONE
17from .notifier import Notifier
18from .tracker import Decision, ReviewTracker, Settings, Wait
20if TYPE_CHECKING:
21 from datetime import datetime
23 from homeassistant.components.mqtt import ReceiveMessage
24 from homeassistant.config_entries import ConfigEntry
26_LOGGER = logging.getLogger(__name__)
28REVIEWS_TOPIC = "{prefix}/reviews"
29DATA_HUBS = "hubs"
32class ReviewWatcher:
33 """The reviews of one camera's or zone's entry"""
35 def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
36 self.hass = hass
37 self.entry = entry
38 settings = Settings.from_options(entry.options)
39 name = entry.data[CONF_SOURCE_NAME]
40 if entry.data.get(CONF_TYPE) == TYPE_ZONE:
41 self.tracker = ReviewTracker(settings, zone=name)
42 else:
43 self.tracker = ReviewTracker(settings, camera=name)
44 self.notifier = Notifier(hass, entry)
45 # each review's notifications tied together, the later ones as children of the first
46 self._contexts: dict[str, Context] = {}
47 self._timers: dict[str, CALLBACK_TYPE] = {}
49 @callback
50 def async_review(self, payload: str | bytes | bytearray) -> None:
51 try:
52 outcome = self.tracker.on_message(payload, time.time())
53 except ValueError, KeyError, TypeError:
54 _LOGGER.warning("CAMERA_AUTONOTIFY Review not understood: %s", payload)
55 return
56 self._act(outcome)
58 @callback
59 def async_stop(self) -> None:
60 for cancel in self._timers.values():
61 cancel()
62 self._timers.clear()
64 @property
65 def reviews(self) -> list[str]:
66 return self.tracker.tracked
68 def _act(self, outcome: Decision | Wait | None) -> None:
69 if isinstance(outcome, Wait):
70 self._wait(outcome)
71 elif isinstance(outcome, Decision):
72 self.entry.async_create_background_task(
73 self.hass,
74 self.notifier.async_notify(outcome, self._context(outcome.review.id)),
75 f"{DOMAIN} notify {outcome.review.id}",
76 )
77 # done with, once the tracker is
78 for review_id in set(self._contexts) - set(self.tracker.tracked):
79 del self._contexts[review_id]
81 def _wait(self, wait: Wait) -> None:
82 if cancel := self._timers.pop(wait.review_id, None):
83 cancel()
85 @callback
86 def due(now: datetime) -> None:
87 self._timers.pop(wait.review_id, None)
88 self._act(self.tracker.on_timer(wait.review_id, time.time()))
90 self._timers[wait.review_id] = async_call_later(self.hass, max(0.0, wait.at - time.time()), due)
92 def _context(self, review_id: str) -> Context:
93 first = self._contexts.get(review_id)
94 if first is None:
95 first = self._contexts[review_id] = Context()
96 return first
97 return Context(parent_id=first.id)
100@dataclass
101class _Hub:
102 unsubscribe: CALLBACK_TYPE
103 watchers: list[ReviewWatcher] = field(default_factory=list)
106def _hubs(hass: HomeAssistant) -> dict[str, _Hub]:
107 data: dict[str, Any] = hass.data.setdefault(DOMAIN, {})
108 return data.setdefault(DATA_HUBS, {})
111async def async_watch(hass: HomeAssistant, prefix: str, watcher: ReviewWatcher) -> CALLBACK_TYPE:
112 """Start the watcher on the reviews of a Frigate server, returning what stops it"""
113 hubs = _hubs(hass)
114 hub = hubs.get(prefix)
115 if hub is None:
117 @callback
118 def received(message: ReceiveMessage) -> None:
119 for each in list(hubs[prefix].watchers):
120 each.async_review(message.payload)
122 unsubscribe = await mqtt.async_subscribe(hass, REVIEWS_TOPIC.format(prefix=prefix), received)
123 # another entry may have subscribed while this one was waiting
124 if (hub := hubs.get(prefix)) is None:
125 hub = hubs[prefix] = _Hub(unsubscribe)
126 else:
127 unsubscribe()
128 hub.watchers.append(watcher)
130 @callback
131 def stop() -> None:
132 watcher.async_stop()
133 if watcher in hub.watchers:
134 hub.watchers.remove(watcher)
135 if not hub.watchers and hubs.get(prefix) is hub:
136 hub.unsubscribe()
137 del hubs[prefix]
139 return stop