Improve comms typing (#18599)

* Enable mypy for comms

* Make zmq data types consistent

* Cleanup inter process typing issues

* Cleanup embeddings typing

* Cleanup config updater

* Cleanup recordings updator

* Make publisher have a generic type

* Cleanup event metadata updater

* Cleanup event metadata updater

* Cleanup detections updater

* Cleanup websocket

* Cleanup mqtt

* Cleanup webpush

* Cleanup dispatcher

* Formatting

* Remove unused

* Add return type

* Fix tests

* Fix semantic triggers config typing

* Cleanup
This commit is contained in:
Nicolas Mowen
2025-08-16 10:20:33 -05:00
committed by Blake Blackshear
parent 1add72884a
commit fcf3824124
29 changed files with 168 additions and 128 deletions
+3 -3
View File
@@ -3,7 +3,7 @@
import multiprocessing as mp
from _pickle import UnpicklingError
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional
from typing import Any
import zmq
@@ -33,7 +33,7 @@ class ConfigPublisher:
class ConfigSubscriber:
"""Simplifies receiving an updated config."""
def __init__(self, topic: str, exact=False) -> None:
def __init__(self, topic: str, exact: bool = False) -> None:
self.topic = topic
self.exact = exact
self.context = zmq.Context()
@@ -41,7 +41,7 @@ class ConfigSubscriber:
self.socket.setsockopt_string(zmq.SUBSCRIBE, topic)
self.socket.connect(SOCKET_PUB_SUB)
def check_for_update(self) -> Optional[tuple[str, Any]]:
def check_for_update(self) -> tuple[str, Any] | tuple[None, None]:
"""Returns updated config or None if no update."""
try:
topic = self.socket.recv_string(flags=zmq.NOBLOCK)
+6 -8
View File
@@ -1,7 +1,7 @@
"""Facilitates communication between processes."""
from enum import Enum
from typing import Any, Optional
from typing import Any
from .zmq_proxy import Publisher, Subscriber
@@ -19,8 +19,7 @@ class DetectionPublisher(Publisher):
topic_base = "detection/"
def __init__(self, topic: DetectionTypeEnum) -> None:
topic = topic.value
def __init__(self, topic: str) -> None:
super().__init__(topic)
@@ -29,16 +28,15 @@ class DetectionSubscriber(Subscriber):
topic_base = "detection/"
def __init__(self, topic: DetectionTypeEnum) -> None:
topic = topic.value
def __init__(self, topic: str) -> None:
super().__init__(topic)
def check_for_update(
self, timeout: float = None
) -> Optional[tuple[DetectionTypeEnum, Any]]:
self, timeout: float | None = None
) -> tuple[str, Any] | tuple[None, None] | None:
return super().check_for_update(timeout)
def _return_object(self, topic: str, payload: Any) -> Any:
if payload is None:
return (None, None)
return (DetectionTypeEnum[topic[len(self.topic_base) :]], payload)
return (topic[len(self.topic_base) :], payload)
+30 -28
View File
@@ -54,10 +54,9 @@ class Dispatcher:
self.ptz_metrics = ptz_metrics
self.comms = communicators
self.camera_activity = CameraActivityManager(config, self.publish)
self.model_state = {}
self.embeddings_reindex = {}
self.birdseye_layout = {}
self.model_state: dict[str, ModelStatusTypesEnum] = {}
self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {}
self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command,
"audio_transcription": self._on_audio_transcription_command,
@@ -88,10 +87,12 @@ class Dispatcher:
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
)
def _receive(self, topic: str, payload: str) -> Optional[Any]:
def _receive(self, topic: str, payload: Any) -> Optional[Any]:
"""Handle receiving of payload from communicators."""
def handle_camera_command(command_type, camera_name, command, payload):
def handle_camera_command(
command_type: str, camera_name: str, command: str, payload: str
) -> None:
try:
if command_type == "set":
self._camera_settings_handlers[command](camera_name, payload)
@@ -100,13 +101,13 @@ class Dispatcher:
except KeyError:
logger.error(f"Invalid command type or handler: {command_type}")
def handle_restart():
def handle_restart() -> None:
restart_frigate()
def handle_insert_many_recordings():
def handle_insert_many_recordings() -> None:
Recordings.insert_many(payload).execute()
def handle_request_region_grid():
def handle_request_region_grid() -> Any:
camera = payload
grid = get_camera_regions_grid(
camera,
@@ -115,24 +116,24 @@ class Dispatcher:
)
return grid
def handle_insert_preview():
def handle_insert_preview() -> None:
Previews.insert(payload).execute()
def handle_upsert_review_segment():
def handle_upsert_review_segment() -> None:
ReviewSegment.insert(payload).on_conflict(
conflict_target=[ReviewSegment.id],
update=payload,
).execute()
def handle_clear_ongoing_review_segments():
def handle_clear_ongoing_review_segments() -> None:
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time.is_null(True)
).execute()
def handle_update_camera_activity():
def handle_update_camera_activity() -> None:
self.camera_activity.update_activity(payload)
def handle_update_event_description():
def handle_update_event_description() -> None:
event: Event = Event.get(Event.id == payload["id"])
event.data["description"] = payload["description"]
event.save()
@@ -148,38 +149,38 @@ class Dispatcher:
),
)
def handle_update_model_state():
def handle_update_model_state() -> None:
if payload:
model = payload["model"]
state = payload["state"]
self.model_state[model] = ModelStatusTypesEnum[state]
self.publish("model_state", json.dumps(self.model_state))
def handle_model_state():
def handle_model_state() -> None:
self.publish("model_state", json.dumps(self.model_state.copy()))
def handle_update_embeddings_reindex_progress():
def handle_update_embeddings_reindex_progress() -> None:
self.embeddings_reindex = payload
self.publish(
"embeddings_reindex_progress",
json.dumps(payload),
)
def handle_embeddings_reindex_progress():
def handle_embeddings_reindex_progress() -> None:
self.publish(
"embeddings_reindex_progress",
json.dumps(self.embeddings_reindex.copy()),
)
def handle_update_birdseye_layout():
def handle_update_birdseye_layout() -> None:
if payload:
self.birdseye_layout = payload
self.publish("birdseye_layout", json.dumps(self.birdseye_layout))
def handle_birdseye_layout():
def handle_birdseye_layout() -> None:
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
def handle_on_connect():
def handle_on_connect() -> None:
camera_status = self.camera_activity.last_camera_activity.copy()
cameras_with_status = camera_status.keys()
@@ -219,7 +220,7 @@ class Dispatcher:
)
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
def handle_notification_test():
def handle_notification_test() -> None:
self.publish("notification_test", "Test notification")
# Dictionary mapping topic to handlers
@@ -266,11 +267,12 @@ class Dispatcher:
logger.error(
f"Received invalid {topic.split('/')[-1]} command: {topic}"
)
return
return None
elif topic in topic_handlers:
return topic_handlers[topic]()
else:
self.publish(topic, payload, retain=False)
return None
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Handle publishing to communicators."""
@@ -373,11 +375,11 @@ class Dispatcher:
if payload == "ON":
if not motion_settings.improve_contrast:
logger.info(f"Turning on improve contrast for {camera_name}")
motion_settings.improve_contrast = True # type: ignore[union-attr]
motion_settings.improve_contrast = True
elif payload == "OFF":
if motion_settings.improve_contrast:
logger.info(f"Turning off improve contrast for {camera_name}")
motion_settings.improve_contrast = False # type: ignore[union-attr]
motion_settings.improve_contrast = False
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
@@ -421,7 +423,7 @@ class Dispatcher:
motion_settings = self.config.cameras[camera_name].motion
logger.info(f"Setting motion contour area for {camera_name}: {payload}")
motion_settings.contour_area = payload # type: ignore[union-attr]
motion_settings.contour_area = payload
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
motion_settings,
@@ -438,7 +440,7 @@ class Dispatcher:
motion_settings = self.config.cameras[camera_name].motion
logger.info(f"Setting motion threshold for {camera_name}: {payload}")
motion_settings.threshold = payload # type: ignore[union-attr]
motion_settings.threshold = payload
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
motion_settings,
@@ -453,7 +455,7 @@ class Dispatcher:
notification_settings = self.config.notifications
logger.info(f"Setting all notifications: {payload}")
notification_settings.enabled = payload == "ON" # type: ignore[union-attr]
notification_settings.enabled = payload == "ON"
self.config_updater.publisher.publish(
"config/notifications", notification_settings
)
+14 -3
View File
@@ -1,10 +1,14 @@
"""Facilitates communication between processes."""
import logging
from enum import Enum
from typing import Any, Callable
import zmq
logger = logging.getLogger(__name__)
SOCKET_REP_REQ = "ipc:///tmp/cache/embeddings"
@@ -41,9 +45,16 @@ class EmbeddingsResponder:
break
try:
(topic, value) = self.socket.recv_json(flags=zmq.NOBLOCK)
raw = self.socket.recv_json(flags=zmq.NOBLOCK)
response = process(topic, value)
if isinstance(raw, list):
(topic, value) = raw
response = process(topic, value)
else:
logging.warning(
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
)
response = None
if response is not None:
self.socket.send_json(response)
@@ -65,7 +76,7 @@ class EmbeddingsRequestor:
self.socket = self.context.socket(zmq.REQ)
self.socket.connect(SOCKET_REP_REQ)
def send_data(self, topic: str, data: Any) -> str:
def send_data(self, topic: str, data: Any) -> Any:
"""Sends data and then waits for reply."""
try:
self.socket.send_json((topic, data))
+5 -4
View File
@@ -28,8 +28,8 @@ class EventMetadataPublisher(Publisher):
def __init__(self) -> None:
super().__init__()
def publish(self, topic: EventMetadataTypeEnum, payload: Any) -> None:
super().publish(payload, topic.value)
def publish(self, payload: Any, sub_topic: str = "") -> None:
super().publish(payload, sub_topic)
class EventMetadataSubscriber(Subscriber):
@@ -40,9 +40,10 @@ class EventMetadataSubscriber(Subscriber):
def __init__(self, topic: EventMetadataTypeEnum) -> None:
super().__init__(topic.value)
def _return_object(self, topic: str, payload: tuple) -> tuple:
def _return_object(
self, topic: str, payload: tuple | None
) -> tuple[str, Any] | tuple[None, None]:
if payload is None:
return (None, None)
topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]]
return (topic, payload)
+14 -6
View File
@@ -7,7 +7,9 @@ from frigate.events.types import EventStateEnum, EventTypeEnum
from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher(Publisher):
class EventUpdatePublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, Any]]]
):
"""Publishes events (objects, audio, manual)."""
topic_base = "event/"
@@ -16,9 +18,11 @@ class EventUpdatePublisher(Publisher):
super().__init__("update")
def publish(
self, payload: tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, Any]]
self,
payload: tuple[EventTypeEnum, EventStateEnum, str, str, dict[str, Any]],
sub_topic: str = "",
) -> None:
super().publish(payload)
super().publish(payload, sub_topic)
class EventUpdateSubscriber(Subscriber):
@@ -30,7 +34,9 @@ class EventUpdateSubscriber(Subscriber):
super().__init__("update")
class EventEndPublisher(Publisher):
class EventEndPublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]]]
):
"""Publishes events that have ended."""
topic_base = "event/"
@@ -39,9 +45,11 @@ class EventEndPublisher(Publisher):
super().__init__("finalized")
def publish(
self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]]
self,
payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, Any]],
sub_topic: str = "",
) -> None:
super().publish(payload)
super().publish(payload, sub_topic)
class EventEndSubscriber(Subscriber):
+13 -3
View File
@@ -1,5 +1,6 @@
"""Facilitates communication between processes."""
import logging
import multiprocessing as mp
import threading
from multiprocessing.synchronize import Event as MpEvent
@@ -9,6 +10,8 @@ import zmq
from frigate.comms.base_communicator import Communicator
logger = logging.getLogger(__name__)
SOCKET_REP_REQ = "ipc:///tmp/cache/comms"
@@ -19,7 +22,7 @@ class InterProcessCommunicator(Communicator):
self.socket.bind(SOCKET_REP_REQ)
self.stop_event: MpEvent = mp.Event()
def publish(self, topic: str, payload: str, retain: bool) -> None:
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""There is no communication back to the processes."""
pass
@@ -37,9 +40,16 @@ class InterProcessCommunicator(Communicator):
break
try:
(topic, value) = self.socket.recv_json(flags=zmq.NOBLOCK)
raw = self.socket.recv_json(flags=zmq.NOBLOCK)
response = self._dispatcher(topic, value)
if isinstance(raw, list):
(topic, value) = raw
response = self._dispatcher(topic, value)
else:
logging.warning(
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
)
response = None
if response is not None:
self.socket.send_json(response)
+6 -6
View File
@@ -11,7 +11,7 @@ from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
class MqttClient(Communicator): # type: ignore[misc]
class MqttClient(Communicator):
"""Frigate wrapper for mqtt client."""
def __init__(self, config: FrigateConfig) -> None:
@@ -75,7 +75,7 @@ class MqttClient(Communicator): # type: ignore[misc]
)
self.publish(
f"{camera_name}/improve_contrast/state",
"ON" if camera.motion.improve_contrast else "OFF", # type: ignore[union-attr]
"ON" if camera.motion.improve_contrast else "OFF",
retain=True,
)
self.publish(
@@ -85,12 +85,12 @@ class MqttClient(Communicator): # type: ignore[misc]
)
self.publish(
f"{camera_name}/motion_threshold/state",
camera.motion.threshold, # type: ignore[union-attr]
camera.motion.threshold,
retain=True,
)
self.publish(
f"{camera_name}/motion_contour_area/state",
camera.motion.contour_area, # type: ignore[union-attr]
camera.motion.contour_area,
retain=True,
)
self.publish(
@@ -150,7 +150,7 @@ class MqttClient(Communicator): # type: ignore[misc]
client: mqtt.Client,
userdata: Any,
flags: Any,
reason_code: mqtt.ReasonCode,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Mqtt connection callback."""
@@ -182,7 +182,7 @@ class MqttClient(Communicator): # type: ignore[misc]
client: mqtt.Client,
userdata: Any,
flags: Any,
reason_code: mqtt.ReasonCode,
reason_code: mqtt.ReasonCode, # type: ignore[name-defined]
properties: Any,
) -> None:
"""Mqtt disconnection callback."""
+5 -7
View File
@@ -13,17 +13,16 @@ class RecordingsDataTypeEnum(str, Enum):
recordings_available_through = "recordings_available_through"
class RecordingsDataPublisher(Publisher):
class RecordingsDataPublisher(Publisher[tuple[str, float]]):
"""Publishes latest recording data."""
topic_base = "recordings/"
def __init__(self, topic: RecordingsDataTypeEnum) -> None:
topic = topic.value
super().__init__(topic)
super().__init__(topic.value)
def publish(self, payload: tuple[str, float]) -> None:
super().publish(payload)
def publish(self, payload: tuple[str, float], sub_topic: str = "") -> None:
super().publish(payload, sub_topic)
class RecordingsDataSubscriber(Subscriber):
@@ -32,5 +31,4 @@ class RecordingsDataSubscriber(Subscriber):
topic_base = "recordings/"
def __init__(self, topic: RecordingsDataTypeEnum) -> None:
topic = topic.value
super().__init__(topic)
super().__init__(topic.value)
+5 -3
View File
@@ -39,7 +39,7 @@ class PushNotification:
ttl: int = 0
class WebPushClient(Communicator): # type: ignore[misc]
class WebPushClient(Communicator):
"""Frigate wrapper for webpush client."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
@@ -50,10 +50,12 @@ class WebPushClient(Communicator): # type: ignore[misc]
self.web_pushers: dict[str, list[WebPusher]] = {}
self.expired_subs: dict[str, list[str]] = {}
self.suspended_cameras: dict[str, int] = {
c.name: 0 for c in self.config.cameras.values()
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
}
self.last_camera_notification_time: dict[str, float] = {
c.name: 0 for c in self.config.cameras.values()
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
}
self.last_notification_time: float = 0
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
+14 -12
View File
@@ -4,7 +4,7 @@ import errno
import json
import logging
import threading
from typing import Callable
from typing import Any, Callable
from wsgiref.simple_server import make_server
from ws4py.server.wsgirefserver import (
@@ -21,8 +21,8 @@ from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)
class WebSocket(WebSocket_):
def unhandled_error(self, error):
class WebSocket(WebSocket_): # type: ignore[misc]
def unhandled_error(self, error: Any) -> None:
"""
Handles the unfriendly socket closures on the server side
without showing a confusing error message
@@ -33,12 +33,12 @@ class WebSocket(WebSocket_):
logging.getLogger("ws4py").exception("Failed to receive data")
class WebSocketClient(Communicator): # type: ignore[misc]
class WebSocketClient(Communicator):
"""Frigate wrapper for ws client."""
def __init__(self, config: FrigateConfig) -> None:
self.config = config
self.websocket_server = None
self.websocket_server: WSGIServer | None = None
def subscribe(self, receiver: Callable) -> None:
self._dispatcher = receiver
@@ -47,10 +47,10 @@ class WebSocketClient(Communicator): # type: ignore[misc]
def start(self) -> None:
"""Start the websocket client."""
class _WebSocketHandler(WebSocket): # type: ignore[misc]
class _WebSocketHandler(WebSocket):
receiver = self._dispatcher
def received_message(self, message: WebSocket.received_message) -> None:
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
try:
json_message = json.loads(message.data.decode("utf-8"))
json_message = {
@@ -86,7 +86,7 @@ class WebSocketClient(Communicator): # type: ignore[misc]
)
self.websocket_thread.start()
def publish(self, topic: str, payload: str, _: bool) -> None:
def publish(self, topic: str, payload: Any, _: bool = False) -> None:
try:
ws_message = json.dumps(
{
@@ -109,9 +109,11 @@ class WebSocketClient(Communicator): # type: ignore[misc]
pass
def stop(self) -> None:
self.websocket_server.manager.close_all()
self.websocket_server.manager.stop()
self.websocket_server.manager.join()
self.websocket_server.shutdown()
if self.websocket_server is not None:
self.websocket_server.manager.close_all()
self.websocket_server.manager.stop()
self.websocket_server.manager.join()
self.websocket_server.shutdown()
self.websocket_thread.join()
logger.info("Exiting websocket client...")
+11 -6
View File
@@ -2,7 +2,7 @@
import json
import threading
from typing import Any, Optional
from typing import Any, Generic, Optional, TypeVar
import zmq
@@ -47,7 +47,10 @@ class ZmqProxy:
self.runner.join()
class Publisher:
T = TypeVar("T")
class Publisher(Generic[T]):
"""Publishes messages."""
topic_base: str = ""
@@ -58,7 +61,7 @@ class Publisher:
self.socket = self.context.socket(zmq.PUB)
self.socket.connect(SOCKET_PUB)
def publish(self, payload: Any, sub_topic: str = "") -> None:
def publish(self, payload: T, sub_topic: str = "") -> None:
"""Publish message."""
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
@@ -80,8 +83,8 @@ class Subscriber:
self.socket.connect(SOCKET_SUB)
def check_for_update(
self, timeout: float = FAST_QUEUE_TIMEOUT
) -> Optional[tuple[str, Any]]:
self, timeout: float | None = FAST_QUEUE_TIMEOUT
) -> tuple[str, Any] | tuple[None, None] | None:
"""Returns message or None if no update."""
try:
has_update, _, _ = zmq.select([self.socket], [], [], timeout)
@@ -98,5 +101,7 @@ class Subscriber:
self.socket.close()
self.context.destroy()
def _return_object(self, topic: str, payload: Any) -> Any:
def _return_object(
self, topic: str, payload: Optional[tuple[str, Any]]
) -> tuple[str, Any] | tuple[None, None] | None:
return payload