2023-01-13 14:18:15 +01:00
|
|
|
"""Handle communication between Frigate and other applications."""
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
import logging
|
|
|
|
from abc import ABC, abstractmethod
|
2024-02-15 01:24:36 +01:00
|
|
|
from typing import Any, Callable, Optional
|
2022-11-24 03:03:20 +01:00
|
|
|
|
2023-10-26 13:20:55 +02:00
|
|
|
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
2023-12-03 15:16:01 +01:00
|
|
|
from frigate.const import INSERT_MANY_RECORDINGS, INSERT_PREVIEW, REQUEST_REGION_GRID
|
|
|
|
from frigate.models import Previews, Recordings
|
2023-07-08 14:04:47 +02:00
|
|
|
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
2023-07-11 13:23:20 +02:00
|
|
|
from frigate.types import CameraMetricsTypes, FeatureMetricsTypes, PTZMetricsTypes
|
2023-10-19 01:21:52 +02:00
|
|
|
from frigate.util.object import get_camera_regions_grid
|
2023-07-06 16:28:50 +02:00
|
|
|
from frigate.util.services import restart_frigate
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class Communicator(ABC):
|
|
|
|
"""pub/sub model via specific protocol."""
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
|
|
"""Send data via specific protocol."""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
def subscribe(self, receiver: Callable) -> None:
|
|
|
|
"""Pass receiver so communicators can pass commands."""
|
|
|
|
pass
|
|
|
|
|
2023-02-04 03:15:47 +01:00
|
|
|
@abstractmethod
|
|
|
|
def stop(self) -> None:
|
|
|
|
"""Stop the communicator."""
|
|
|
|
pass
|
|
|
|
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
class Dispatcher:
|
2023-01-13 14:18:15 +01:00
|
|
|
"""Handle communication between Frigate and communicators."""
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
config: FrigateConfig,
|
2023-04-26 13:08:53 +02:00
|
|
|
onvif: OnvifController,
|
2022-11-24 03:03:20 +01:00
|
|
|
camera_metrics: dict[str, CameraMetricsTypes],
|
2023-07-01 15:18:33 +02:00
|
|
|
feature_metrics: dict[str, FeatureMetricsTypes],
|
2023-07-11 13:23:20 +02:00
|
|
|
ptz_metrics: dict[str, PTZMetricsTypes],
|
2022-11-24 03:03:20 +01:00
|
|
|
communicators: list[Communicator],
|
|
|
|
) -> None:
|
|
|
|
self.config = config
|
2023-04-26 13:08:53 +02:00
|
|
|
self.onvif = onvif
|
2022-11-24 03:03:20 +01:00
|
|
|
self.camera_metrics = camera_metrics
|
2023-07-01 15:18:33 +02:00
|
|
|
self.feature_metrics = feature_metrics
|
2023-07-11 13:23:20 +02:00
|
|
|
self.ptz_metrics = ptz_metrics
|
2022-11-24 03:03:20 +01:00
|
|
|
self.comms = communicators
|
|
|
|
|
|
|
|
self._camera_settings_handlers: dict[str, Callable] = {
|
2023-07-01 15:18:33 +02:00
|
|
|
"audio": self._on_audio_command,
|
2022-11-24 03:03:20 +01:00
|
|
|
"detect": self._on_detect_command,
|
|
|
|
"improve_contrast": self._on_motion_improve_contrast_command,
|
2023-07-08 14:04:47 +02:00
|
|
|
"ptz_autotracker": self._on_ptz_autotracker_command,
|
2022-11-24 03:03:20 +01:00
|
|
|
"motion": self._on_motion_command,
|
|
|
|
"motion_contour_area": self._on_motion_contour_area_command,
|
|
|
|
"motion_threshold": self._on_motion_threshold_command,
|
2022-11-29 01:58:41 +01:00
|
|
|
"recordings": self._on_recordings_command,
|
2022-11-24 03:03:20 +01:00
|
|
|
"snapshots": self._on_snapshots_command,
|
2023-10-26 13:20:55 +02:00
|
|
|
"birdseye": self._on_birdseye_command,
|
|
|
|
"birdseye_mode": self._on_birdseye_mode_command,
|
2022-11-24 03:03:20 +01:00
|
|
|
}
|
|
|
|
|
2023-09-01 14:06:59 +02:00
|
|
|
for comm in self.comms:
|
|
|
|
comm.subscribe(self._receive)
|
|
|
|
|
2024-02-15 01:24:36 +01:00
|
|
|
def _receive(self, topic: str, payload: str) -> Optional[Any]:
|
2022-11-24 03:03:20 +01:00
|
|
|
"""Handle receiving of payload from communicators."""
|
|
|
|
if topic.endswith("set"):
|
|
|
|
try:
|
2023-04-26 13:08:53 +02:00
|
|
|
# example /cam_name/detect/set payload=ON|OFF
|
2022-11-24 03:03:20 +01:00
|
|
|
camera_name = topic.split("/")[-3]
|
|
|
|
command = topic.split("/")[-2]
|
|
|
|
self._camera_settings_handlers[command](camera_name, payload)
|
2023-05-29 12:31:17 +02:00
|
|
|
except IndexError:
|
2022-11-24 03:03:20 +01:00
|
|
|
logger.error(f"Received invalid set command: {topic}")
|
|
|
|
return
|
2023-04-26 13:08:53 +02:00
|
|
|
elif topic.endswith("ptz"):
|
|
|
|
try:
|
|
|
|
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
|
|
|
|
camera_name = topic.split("/")[-2]
|
|
|
|
self._on_ptz_command(camera_name, payload)
|
2023-05-29 12:31:17 +02:00
|
|
|
except IndexError:
|
2023-04-26 13:08:53 +02:00
|
|
|
logger.error(f"Received invalid ptz command: {topic}")
|
|
|
|
return
|
2022-11-24 03:03:20 +01:00
|
|
|
elif topic == "restart":
|
|
|
|
restart_frigate()
|
2023-07-26 12:55:08 +02:00
|
|
|
elif topic == INSERT_MANY_RECORDINGS:
|
|
|
|
Recordings.insert_many(payload).execute()
|
2023-10-19 01:21:52 +02:00
|
|
|
elif topic == REQUEST_REGION_GRID:
|
|
|
|
camera = payload
|
2024-02-15 01:24:36 +01:00
|
|
|
grid = get_camera_regions_grid(
|
|
|
|
camera,
|
|
|
|
self.config.cameras[camera].detect,
|
|
|
|
max(self.config.model.width, self.config.model.height),
|
2023-10-19 01:21:52 +02:00
|
|
|
)
|
2024-02-15 01:24:36 +01:00
|
|
|
return grid
|
2023-12-03 15:16:01 +01:00
|
|
|
elif topic == INSERT_PREVIEW:
|
|
|
|
Previews.insert(payload).execute()
|
2023-07-14 02:52:33 +02:00
|
|
|
else:
|
|
|
|
self.publish(topic, payload, retain=False)
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
|
|
"""Handle publishing to communicators."""
|
|
|
|
for comm in self.comms:
|
|
|
|
comm.publish(topic, payload, retain)
|
|
|
|
|
2023-02-04 03:15:47 +01:00
|
|
|
def stop(self) -> None:
|
|
|
|
for comm in self.comms:
|
|
|
|
comm.stop()
|
|
|
|
|
2022-11-24 03:03:20 +01:00
|
|
|
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for detect topic."""
|
|
|
|
detect_settings = self.config.cameras[camera_name].detect
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
if not self.camera_metrics[camera_name]["detection_enabled"].value:
|
|
|
|
logger.info(f"Turning on detection for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["detection_enabled"].value = True
|
|
|
|
detect_settings.enabled = True
|
|
|
|
|
|
|
|
if not self.camera_metrics[camera_name]["motion_enabled"].value:
|
|
|
|
logger.info(
|
|
|
|
f"Turning on motion for {camera_name} due to detection being enabled."
|
|
|
|
)
|
|
|
|
self.camera_metrics[camera_name]["motion_enabled"].value = True
|
|
|
|
self.publish(f"{camera_name}/motion/state", payload, retain=True)
|
|
|
|
elif payload == "OFF":
|
|
|
|
if self.camera_metrics[camera_name]["detection_enabled"].value:
|
|
|
|
logger.info(f"Turning off detection for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["detection_enabled"].value = False
|
|
|
|
detect_settings.enabled = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/detect/state", payload, retain=True)
|
|
|
|
|
|
|
|
def _on_motion_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for motion topic."""
|
|
|
|
if payload == "ON":
|
|
|
|
if not self.camera_metrics[camera_name]["motion_enabled"].value:
|
|
|
|
logger.info(f"Turning on motion for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["motion_enabled"].value = True
|
|
|
|
elif payload == "OFF":
|
|
|
|
if self.camera_metrics[camera_name]["detection_enabled"].value:
|
|
|
|
logger.error(
|
2023-05-29 12:31:17 +02:00
|
|
|
"Turning off motion is not allowed when detection is enabled."
|
2022-11-24 03:03:20 +01:00
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
if self.camera_metrics[camera_name]["motion_enabled"].value:
|
|
|
|
logger.info(f"Turning off motion for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["motion_enabled"].value = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/motion/state", payload, retain=True)
|
|
|
|
|
|
|
|
def _on_motion_improve_contrast_command(
|
|
|
|
self, camera_name: str, payload: str
|
|
|
|
) -> None:
|
|
|
|
"""Callback for improve_contrast topic."""
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
if not self.camera_metrics[camera_name]["improve_contrast_enabled"].value:
|
|
|
|
logger.info(f"Turning on improve contrast for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name][
|
|
|
|
"improve_contrast_enabled"
|
|
|
|
].value = True
|
|
|
|
motion_settings.improve_contrast = True # type: ignore[union-attr]
|
|
|
|
elif payload == "OFF":
|
|
|
|
if self.camera_metrics[camera_name]["improve_contrast_enabled"].value:
|
|
|
|
logger.info(f"Turning off improve contrast for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name][
|
|
|
|
"improve_contrast_enabled"
|
|
|
|
].value = False
|
|
|
|
motion_settings.improve_contrast = False # type: ignore[union-attr]
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/improve_contrast/state", payload, retain=True)
|
|
|
|
|
2023-07-08 14:04:47 +02:00
|
|
|
def _on_ptz_autotracker_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for ptz_autotracker topic."""
|
|
|
|
ptz_autotracker_settings = self.config.cameras[camera_name].onvif.autotracking
|
|
|
|
|
|
|
|
if payload == "ON":
|
2023-11-16 02:25:48 +01:00
|
|
|
if not self.config.cameras[
|
|
|
|
camera_name
|
|
|
|
].onvif.autotracking.enabled_in_config:
|
|
|
|
logger.error(
|
|
|
|
"Autotracking must be enabled in the config to be turned on via MQTT."
|
|
|
|
)
|
|
|
|
return
|
2023-07-11 13:23:20 +02:00
|
|
|
if not self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
2023-07-08 14:04:47 +02:00
|
|
|
logger.info(f"Turning on ptz autotracker for {camera_name}")
|
2023-07-11 13:23:20 +02:00
|
|
|
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = True
|
2023-10-22 18:59:13 +02:00
|
|
|
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
2023-07-08 14:04:47 +02:00
|
|
|
ptz_autotracker_settings.enabled = True
|
|
|
|
elif payload == "OFF":
|
2023-07-11 13:23:20 +02:00
|
|
|
if self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value:
|
2023-07-08 14:04:47 +02:00
|
|
|
logger.info(f"Turning off ptz autotracker for {camera_name}")
|
2023-07-11 13:23:20 +02:00
|
|
|
self.ptz_metrics[camera_name]["ptz_autotracker_enabled"].value = False
|
2023-10-22 18:59:13 +02:00
|
|
|
self.ptz_metrics[camera_name]["ptz_start_time"].value = 0
|
2023-07-08 14:04:47 +02:00
|
|
|
ptz_autotracker_settings.enabled = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
|
|
|
|
|
2022-11-24 03:03:20 +01:00
|
|
|
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
|
|
|
|
"""Callback for motion contour topic."""
|
|
|
|
try:
|
|
|
|
payload = int(payload)
|
|
|
|
except ValueError:
|
|
|
|
f"Received unsupported value for motion contour area: {payload}"
|
|
|
|
return
|
|
|
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
logger.info(f"Setting motion contour area for {camera_name}: {payload}")
|
|
|
|
self.camera_metrics[camera_name]["motion_contour_area"].value = payload
|
|
|
|
motion_settings.contour_area = payload # type: ignore[union-attr]
|
|
|
|
self.publish(f"{camera_name}/motion_contour_area/state", payload, retain=True)
|
|
|
|
|
|
|
|
def _on_motion_threshold_command(self, camera_name: str, payload: int) -> None:
|
|
|
|
"""Callback for motion threshold topic."""
|
|
|
|
try:
|
|
|
|
payload = int(payload)
|
|
|
|
except ValueError:
|
|
|
|
f"Received unsupported value for motion threshold: {payload}"
|
|
|
|
return
|
|
|
|
|
|
|
|
motion_settings = self.config.cameras[camera_name].motion
|
|
|
|
logger.info(f"Setting motion threshold for {camera_name}: {payload}")
|
|
|
|
self.camera_metrics[camera_name]["motion_threshold"].value = payload
|
|
|
|
motion_settings.threshold = payload # type: ignore[union-attr]
|
|
|
|
self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True)
|
|
|
|
|
2023-07-01 15:18:33 +02:00
|
|
|
def _on_audio_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for audio topic."""
|
|
|
|
audio_settings = self.config.cameras[camera_name].audio
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
if not self.config.cameras[camera_name].audio.enabled_in_config:
|
|
|
|
logger.error(
|
|
|
|
"Audio detection must be enabled in the config to be turned on via MQTT."
|
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
if not audio_settings.enabled:
|
|
|
|
logger.info(f"Turning on audio detection for {camera_name}")
|
|
|
|
audio_settings.enabled = True
|
|
|
|
self.feature_metrics[camera_name]["audio_enabled"].value = True
|
|
|
|
elif payload == "OFF":
|
|
|
|
if self.feature_metrics[camera_name]["audio_enabled"].value:
|
|
|
|
logger.info(f"Turning off audio detection for {camera_name}")
|
|
|
|
audio_settings.enabled = False
|
|
|
|
self.feature_metrics[camera_name]["audio_enabled"].value = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/audio/state", payload, retain=True)
|
|
|
|
|
2022-11-24 03:03:20 +01:00
|
|
|
def _on_recordings_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for recordings topic."""
|
|
|
|
record_settings = self.config.cameras[camera_name].record
|
|
|
|
|
|
|
|
if payload == "ON":
|
2023-05-15 14:36:26 +02:00
|
|
|
if not self.config.cameras[camera_name].record.enabled_in_config:
|
|
|
|
logger.error(
|
2023-05-29 12:31:17 +02:00
|
|
|
"Recordings must be enabled in the config to be turned on via MQTT."
|
2023-05-15 14:36:26 +02:00
|
|
|
)
|
|
|
|
return
|
|
|
|
|
2022-11-24 03:03:20 +01:00
|
|
|
if not record_settings.enabled:
|
|
|
|
logger.info(f"Turning on recordings for {camera_name}")
|
|
|
|
record_settings.enabled = True
|
2023-07-01 15:18:33 +02:00
|
|
|
self.feature_metrics[camera_name]["record_enabled"].value = True
|
2022-11-24 03:03:20 +01:00
|
|
|
elif payload == "OFF":
|
2023-07-01 15:18:33 +02:00
|
|
|
if self.feature_metrics[camera_name]["record_enabled"].value:
|
2022-11-24 03:03:20 +01:00
|
|
|
logger.info(f"Turning off recordings for {camera_name}")
|
|
|
|
record_settings.enabled = False
|
2023-07-01 15:18:33 +02:00
|
|
|
self.feature_metrics[camera_name]["record_enabled"].value = False
|
2022-11-24 03:03:20 +01:00
|
|
|
|
|
|
|
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
|
|
|
|
|
|
|
|
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for snapshots topic."""
|
|
|
|
snapshots_settings = self.config.cameras[camera_name].snapshots
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
if not snapshots_settings.enabled:
|
|
|
|
logger.info(f"Turning on snapshots for {camera_name}")
|
|
|
|
snapshots_settings.enabled = True
|
|
|
|
elif payload == "OFF":
|
|
|
|
if snapshots_settings.enabled:
|
|
|
|
logger.info(f"Turning off snapshots for {camera_name}")
|
|
|
|
snapshots_settings.enabled = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
|
2023-04-26 13:08:53 +02:00
|
|
|
|
|
|
|
def _on_ptz_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for ptz topic."""
|
|
|
|
try:
|
|
|
|
if "preset" in payload.lower():
|
|
|
|
command = OnvifCommandEnum.preset
|
2023-07-06 14:42:17 +02:00
|
|
|
param = payload.lower()[payload.index("_") + 1 :]
|
2023-04-26 13:08:53 +02:00
|
|
|
else:
|
|
|
|
command = OnvifCommandEnum[payload.lower()]
|
|
|
|
param = ""
|
|
|
|
|
|
|
|
self.onvif.handle_command(camera_name, command, param)
|
|
|
|
logger.info(f"Setting ptz command to {command} for {camera_name}")
|
|
|
|
except KeyError as k:
|
|
|
|
logger.error(f"Invalid PTZ command {payload}: {k}")
|
2023-10-26 13:20:55 +02:00
|
|
|
|
|
|
|
def _on_birdseye_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for birdseye topic."""
|
|
|
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
|
|
|
|
|
|
|
if payload == "ON":
|
|
|
|
if not self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
|
|
|
logger.info(f"Turning on birdseye for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["birdseye_enabled"].value = True
|
|
|
|
birdseye_settings.enabled = True
|
|
|
|
|
|
|
|
elif payload == "OFF":
|
|
|
|
if self.camera_metrics[camera_name]["birdseye_enabled"].value:
|
|
|
|
logger.info(f"Turning off birdseye for {camera_name}")
|
|
|
|
self.camera_metrics[camera_name]["birdseye_enabled"].value = False
|
|
|
|
birdseye_settings.enabled = False
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/birdseye/state", payload, retain=True)
|
|
|
|
|
|
|
|
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
|
|
|
"""Callback for birdseye mode topic."""
|
|
|
|
|
|
|
|
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
|
|
|
|
logger.info(f"Invalid birdseye_mode command: {payload}")
|
|
|
|
return
|
|
|
|
|
|
|
|
birdseye_config = self.config.cameras[camera_name].birdseye
|
|
|
|
if not birdseye_config.enabled:
|
|
|
|
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
|
|
|
return
|
|
|
|
|
|
|
|
new_birdseye_mode = BirdseyeModeEnum(payload.lower())
|
|
|
|
logger.info(f"Setting birdseye mode for {camera_name} to {new_birdseye_mode}")
|
|
|
|
|
|
|
|
# update the metric (need the mode converted to an int)
|
|
|
|
self.camera_metrics[camera_name][
|
|
|
|
"birdseye_mode"
|
|
|
|
].value = BirdseyeModeEnum.get_index(new_birdseye_mode)
|
|
|
|
|
|
|
|
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|