2026-03-25 08:44:12 -06:00
|
|
|
"""Manages ffmpeg processes for camera frame capture."""
|
|
|
|
|
|
2020-11-04 06:31:25 -06:00
|
|
|
import logging
|
|
|
|
|
import queue
|
2021-10-31 11:12:44 -05:00
|
|
|
import subprocess as sp
|
2020-11-04 06:31:25 -06:00
|
|
|
import threading
|
|
|
|
|
import time
|
2025-12-15 15:02:03 -06:00
|
|
|
from collections import deque
|
2026-07-06 09:28:02 -08:00
|
|
|
from datetime import UTC, datetime, timedelta
|
2025-04-23 17:06:06 -06:00
|
|
|
from multiprocessing import Queue, Value
|
|
|
|
|
from multiprocessing.synchronize import Event as MpEvent
|
|
|
|
|
from typing import Any
|
2020-11-04 06:31:25 -06:00
|
|
|
|
2026-03-25 08:44:12 -06:00
|
|
|
from frigate.camera import CameraMetrics
|
2024-02-14 17:24:36 -07:00
|
|
|
from frigate.comms.inter_process import InterProcessRequestor
|
2025-09-28 11:52:14 -05:00
|
|
|
from frigate.comms.recordings_updater import (
|
|
|
|
|
RecordingsDataSubscriber,
|
|
|
|
|
RecordingsDataTypeEnum,
|
|
|
|
|
)
|
2026-03-25 08:44:12 -06:00
|
|
|
from frigate.config import CameraConfig, LoggerConfig
|
2026-10-01 09:56:27 -05:00
|
|
|
from frigate.config.camera.ffmpeg import CameraRoleEnum
|
2025-05-22 12:16:51 -06:00
|
|
|
from frigate.config.camera.updater import (
|
|
|
|
|
CameraConfigUpdateEnum,
|
|
|
|
|
CameraConfigUpdateSubscriber,
|
|
|
|
|
)
|
2026-03-25 08:44:12 -06:00
|
|
|
from frigate.const import PROCESS_PRIORITY_HIGH
|
2020-12-04 06:59:03 -06:00
|
|
|
from frigate.log import LogPipe
|
2026-06-11 15:16:41 -05:00
|
|
|
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
2026-03-25 08:44:12 -06:00
|
|
|
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
2023-07-06 08:28:50 -06:00
|
|
|
from frigate.util.image import (
|
2021-02-17 07:23:32 -06:00
|
|
|
FrameManager,
|
|
|
|
|
SharedMemoryFrameManager,
|
|
|
|
|
)
|
2025-06-13 11:09:51 -06:00
|
|
|
from frigate.util.process import FrigateProcess
|
2019-02-25 20:27:02 -06:00
|
|
|
|
2020-11-03 21:26:39 -06:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-09-20 13:44:47 -05:00
|
|
|
RECORD_GRACE_SECONDS = 90
|
|
|
|
|
|
2021-02-17 07:23:32 -06:00
|
|
|
|
|
|
|
|
def capture_frames(
|
2025-08-22 06:42:36 -06:00
|
|
|
ffmpeg_process: sp.Popen[Any],
|
2024-09-03 10:22:30 -06:00
|
|
|
config: CameraConfig,
|
|
|
|
|
shm_frame_count: int,
|
2024-11-17 10:25:49 -07:00
|
|
|
frame_index: int,
|
|
|
|
|
frame_shape: tuple[int, int],
|
2021-02-17 07:23:32 -06:00
|
|
|
frame_manager: FrameManager,
|
|
|
|
|
frame_queue,
|
2025-04-23 17:06:06 -06:00
|
|
|
fps: Value,
|
|
|
|
|
skipped_fps: Value,
|
|
|
|
|
current_frame: Value,
|
|
|
|
|
stop_event: MpEvent,
|
2025-08-22 06:42:36 -06:00
|
|
|
) -> None:
|
2020-11-03 08:15:58 -06:00
|
|
|
frame_size = frame_shape[0] * frame_shape[1]
|
2020-10-25 10:05:21 -05:00
|
|
|
frame_rate = EventsPerSecond()
|
2020-10-26 07:59:22 -05:00
|
|
|
frame_rate.start()
|
2020-10-25 10:05:21 -05:00
|
|
|
skipped_eps = EventsPerSecond()
|
|
|
|
|
skipped_eps.start()
|
2025-12-15 15:02:03 -06:00
|
|
|
|
2025-05-22 12:16:51 -06:00
|
|
|
config_subscriber = CameraConfigUpdateSubscriber(
|
2025-06-11 11:25:30 -06:00
|
|
|
None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
|
2025-05-22 12:16:51 -06:00
|
|
|
)
|
2025-03-03 09:30:52 -06:00
|
|
|
|
|
|
|
|
def get_enabled_state():
|
|
|
|
|
"""Fetch the latest enabled state from ZMQ."""
|
2025-05-22 12:16:51 -06:00
|
|
|
config_subscriber.check_for_updates()
|
2025-03-03 09:30:52 -06:00
|
|
|
return config.enabled
|
|
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
try:
|
|
|
|
|
while not stop_event.is_set():
|
|
|
|
|
if not get_enabled_state():
|
|
|
|
|
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
2023-02-04 08:58:45 -06:00
|
|
|
break
|
2024-09-03 10:22:30 -06:00
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
fps.value = frame_rate.eps()
|
|
|
|
|
skipped_fps.value = skipped_eps.eps()
|
|
|
|
|
current_frame.value = datetime.now().timestamp()
|
|
|
|
|
frame_name = f"{config.name}_frame{frame_index}"
|
|
|
|
|
frame_buffer = frame_manager.write(frame_name)
|
|
|
|
|
try:
|
|
|
|
|
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
|
|
|
|
except Exception:
|
|
|
|
|
# shutdown has been initiated
|
|
|
|
|
if stop_event.is_set():
|
|
|
|
|
break
|
2020-08-22 07:05:20 -05:00
|
|
|
|
2022-02-06 08:46:41 -06:00
|
|
|
logger.error(
|
2025-11-30 06:54:42 -06:00
|
|
|
f"{config.name}: Unable to read frames from ffmpeg process."
|
2021-02-17 07:23:32 -06:00
|
|
|
)
|
2024-09-03 10:22:30 -06:00
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
if ffmpeg_process.poll() is not None:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
|
|
|
|
|
)
|
|
|
|
|
break
|
2020-08-22 07:05:20 -05:00
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
continue
|
2020-08-22 07:05:20 -05:00
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
frame_rate.update()
|
2024-09-03 10:22:30 -06:00
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
# don't lock the queue to check, just try since it should rarely be full
|
|
|
|
|
try:
|
|
|
|
|
# add to the queue
|
|
|
|
|
frame_queue.put((frame_name, current_frame.value), False)
|
|
|
|
|
frame_manager.close(frame_name)
|
|
|
|
|
except queue.Full:
|
|
|
|
|
# if the queue is full, skip this frame
|
|
|
|
|
skipped_eps.update()
|
|
|
|
|
|
|
|
|
|
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
|
|
|
|
finally:
|
|
|
|
|
config_subscriber.stop()
|
2024-11-16 16:00:19 -07:00
|
|
|
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2020-10-25 10:05:21 -05:00
|
|
|
class CameraWatchdog(threading.Thread):
|
2021-02-17 07:23:32 -06:00
|
|
|
def __init__(
|
2022-12-08 20:03:54 -07:00
|
|
|
self,
|
|
|
|
|
config: CameraConfig,
|
2024-09-03 10:22:30 -06:00
|
|
|
shm_frame_count: int,
|
2025-04-23 17:06:06 -06:00
|
|
|
frame_queue: Queue,
|
2022-12-08 20:03:54 -07:00
|
|
|
camera_fps,
|
2023-06-28 04:53:28 -06:00
|
|
|
skipped_fps,
|
2022-12-08 20:03:54 -07:00
|
|
|
ffmpeg_pid,
|
2025-12-15 15:02:03 -06:00
|
|
|
stalls,
|
|
|
|
|
reconnects,
|
|
|
|
|
detection_frame,
|
2022-12-08 20:03:54 -07:00
|
|
|
stop_event,
|
2021-02-17 07:23:32 -06:00
|
|
|
):
|
2020-10-25 10:05:21 -05:00
|
|
|
threading.Thread.__init__(self)
|
2025-05-22 12:16:51 -06:00
|
|
|
self.logger = logging.getLogger(f"watchdog.{config.name}")
|
2020-10-25 10:05:21 -05:00
|
|
|
self.config = config
|
2024-09-03 10:22:30 -06:00
|
|
|
self.shm_frame_count = shm_frame_count
|
2020-10-25 10:05:21 -05:00
|
|
|
self.capture_thread = None
|
2020-11-29 15:55:53 -06:00
|
|
|
self.ffmpeg_detect_process = None
|
2025-05-22 12:16:51 -06:00
|
|
|
self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.detect")
|
2025-05-13 16:27:20 +02:00
|
|
|
self.ffmpeg_other_processes: list[dict[str, Any]] = []
|
2020-10-25 10:05:21 -05:00
|
|
|
self.camera_fps = camera_fps
|
2023-06-28 04:53:28 -06:00
|
|
|
self.skipped_fps = skipped_fps
|
2020-10-26 07:59:05 -05:00
|
|
|
self.ffmpeg_pid = ffmpeg_pid
|
2020-10-25 10:05:21 -05:00
|
|
|
self.frame_queue = frame_queue
|
2020-11-03 08:15:58 -06:00
|
|
|
self.frame_shape = self.config.frame_shape_yuv
|
|
|
|
|
self.frame_size = self.frame_shape[0] * self.frame_shape[1]
|
2024-09-10 07:39:37 -06:00
|
|
|
self.fps_overflow_count = 0
|
2024-11-17 10:25:49 -07:00
|
|
|
self.frame_index = 0
|
2020-11-29 16:19:59 -06:00
|
|
|
self.stop_event = stop_event
|
2023-06-30 08:14:39 -04:00
|
|
|
self.sleeptime = self.config.ffmpeg.retry_interval
|
2025-12-15 15:02:03 -06:00
|
|
|
self.reconnect_timestamps = deque()
|
|
|
|
|
self.stalls = stalls
|
|
|
|
|
self.reconnects = reconnects
|
|
|
|
|
self.detection_frame = detection_frame
|
2020-10-25 10:05:21 -05:00
|
|
|
|
2025-05-22 12:16:51 -06:00
|
|
|
self.config_subscriber = CameraConfigUpdateSubscriber(
|
2025-11-06 07:21:07 -07:00
|
|
|
None,
|
|
|
|
|
{config.name: config},
|
2026-03-06 14:45:39 -06:00
|
|
|
[
|
|
|
|
|
CameraConfigUpdateEnum.enabled,
|
|
|
|
|
CameraConfigUpdateEnum.ffmpeg,
|
|
|
|
|
CameraConfigUpdateEnum.record,
|
|
|
|
|
],
|
2025-05-22 12:16:51 -06:00
|
|
|
)
|
2025-08-22 06:42:36 -06:00
|
|
|
self.requestor = InterProcessRequestor()
|
2025-03-03 09:30:52 -06:00
|
|
|
self.was_enabled = self.config.enabled
|
2026-05-07 08:53:07 -05:00
|
|
|
self.was_record_enabled_in_config = self.config.record.enabled_in_config
|
2020-11-29 15:55:53 -06:00
|
|
|
|
2025-09-28 11:52:14 -05:00
|
|
|
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
|
|
|
|
|
self.latest_valid_segment_time: float = 0
|
|
|
|
|
self.latest_invalid_segment_time: float = 0
|
|
|
|
|
self.latest_cache_segment_time: float = 0
|
2026-02-12 14:42:08 -06:00
|
|
|
self.record_enable_time: datetime | None = None
|
2026-09-20 13:44:47 -05:00
|
|
|
self.record_grace_until: datetime | None = None
|
2025-09-28 11:52:14 -05:00
|
|
|
|
2026-04-29 17:20:19 -05:00
|
|
|
# `valid` segments are published with the segment's start time, so the
|
|
|
|
|
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
|
|
|
|
# staleness threshold so it's never tighter than that worst case.
|
2026-06-11 15:16:41 -05:00
|
|
|
segment_time = get_record_segment_time(self.config)
|
2026-04-29 17:20:19 -05:00
|
|
|
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
|
|
|
|
|
2025-12-15 15:02:03 -06:00
|
|
|
# Stall tracking (based on last processed frame)
|
|
|
|
|
self._stall_timestamps: deque[float] = deque()
|
|
|
|
|
self._stall_active: bool = False
|
|
|
|
|
|
|
|
|
|
# Status caching to reduce message volume
|
|
|
|
|
self._last_detect_status: str | None = None
|
|
|
|
|
self._last_record_status: str | None = None
|
2026-10-01 09:56:27 -05:00
|
|
|
self._last_detect_status_update_time: float = 0.0
|
|
|
|
|
self._last_record_status_update_time: float = 0.0
|
2025-12-15 15:02:03 -06:00
|
|
|
|
|
|
|
|
def _send_detect_status(self, status: str, now: float) -> None:
|
|
|
|
|
"""Send detect status only if changed or retry_interval has elapsed."""
|
|
|
|
|
if (
|
|
|
|
|
status != self._last_detect_status
|
2026-10-01 09:56:27 -05:00
|
|
|
or (now - self._last_detect_status_update_time) >= self.sleeptime
|
2025-12-15 15:02:03 -06:00
|
|
|
):
|
|
|
|
|
self.requestor.send_data(f"{self.config.name}/status/detect", status)
|
|
|
|
|
self._last_detect_status = status
|
2026-10-01 09:56:27 -05:00
|
|
|
self._last_detect_status_update_time = now
|
2025-12-15 15:02:03 -06:00
|
|
|
|
|
|
|
|
def _send_record_status(self, status: str, now: float) -> None:
|
|
|
|
|
"""Send record status only if changed or retry_interval has elapsed."""
|
|
|
|
|
if (
|
|
|
|
|
status != self._last_record_status
|
2026-10-01 09:56:27 -05:00
|
|
|
or (now - self._last_record_status_update_time) >= self.sleeptime
|
2025-12-15 15:02:03 -06:00
|
|
|
):
|
|
|
|
|
self.requestor.send_data(f"{self.config.name}/status/record", status)
|
|
|
|
|
self._last_record_status = status
|
2026-10-01 09:56:27 -05:00
|
|
|
self._last_record_status_update_time = now
|
|
|
|
|
|
|
|
|
|
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
|
|
|
|
|
"""Send offline status for each role of a restarted ffmpeg process."""
|
|
|
|
|
for role in roles:
|
|
|
|
|
if role == CameraRoleEnum.record:
|
|
|
|
|
self._send_record_status("offline", now)
|
|
|
|
|
else:
|
|
|
|
|
self.requestor.send_data(
|
|
|
|
|
f"{self.config.name}/status/{role.value}", "offline"
|
|
|
|
|
)
|
2025-12-15 15:02:03 -06:00
|
|
|
|
2026-03-06 14:45:39 -06:00
|
|
|
def _check_config_updates(self) -> dict[str, list[str]]:
|
|
|
|
|
"""Check for config updates and return the update dict."""
|
|
|
|
|
return self.config_subscriber.check_for_updates()
|
|
|
|
|
|
2025-03-03 09:30:52 -06:00
|
|
|
def _update_enabled_state(self) -> bool:
|
|
|
|
|
"""Fetch the latest config and update enabled state."""
|
2026-03-06 14:45:39 -06:00
|
|
|
self._check_config_updates()
|
2025-03-05 07:30:23 -06:00
|
|
|
return self.config.enabled
|
2025-03-03 09:30:52 -06:00
|
|
|
|
2025-07-11 13:19:43 -06:00
|
|
|
def reset_capture_thread(
|
|
|
|
|
self, terminate: bool = True, drain_output: bool = True
|
|
|
|
|
) -> None:
|
|
|
|
|
if terminate:
|
|
|
|
|
self.ffmpeg_detect_process.terminate()
|
|
|
|
|
try:
|
|
|
|
|
self.logger.info("Waiting for ffmpeg to exit gracefully...")
|
|
|
|
|
|
|
|
|
|
if drain_output:
|
|
|
|
|
self.ffmpeg_detect_process.communicate(timeout=30)
|
|
|
|
|
else:
|
|
|
|
|
self.ffmpeg_detect_process.wait(timeout=30)
|
|
|
|
|
except sp.TimeoutExpired:
|
|
|
|
|
self.logger.info("FFmpeg did not exit. Force killing...")
|
|
|
|
|
self.ffmpeg_detect_process.kill()
|
|
|
|
|
|
|
|
|
|
if drain_output:
|
|
|
|
|
self.ffmpeg_detect_process.communicate()
|
|
|
|
|
else:
|
|
|
|
|
self.ffmpeg_detect_process.wait()
|
|
|
|
|
|
2025-12-15 15:02:03 -06:00
|
|
|
# Update reconnects
|
|
|
|
|
now = datetime.now().timestamp()
|
|
|
|
|
self.reconnect_timestamps.append(now)
|
|
|
|
|
while self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600:
|
|
|
|
|
self.reconnect_timestamps.popleft()
|
|
|
|
|
if self.reconnects:
|
|
|
|
|
self.reconnects.value = len(self.reconnect_timestamps)
|
|
|
|
|
|
2025-11-30 06:54:42 -06:00
|
|
|
# Wait for old capture thread to fully exit before starting a new one
|
|
|
|
|
if self.capture_thread is not None and self.capture_thread.is_alive():
|
|
|
|
|
self.logger.info("Waiting for capture thread to exit...")
|
|
|
|
|
self.capture_thread.join(timeout=5)
|
|
|
|
|
|
|
|
|
|
if self.capture_thread.is_alive():
|
|
|
|
|
self.logger.warning(
|
|
|
|
|
f"Capture thread for {self.config.name} did not exit in time"
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-11 13:19:43 -06:00
|
|
|
self.logger.error(
|
|
|
|
|
"The following ffmpeg logs include the last 100 lines prior to exit."
|
|
|
|
|
)
|
|
|
|
|
self.logpipe.dump()
|
|
|
|
|
self.logger.info("Restarting ffmpeg...")
|
|
|
|
|
self.start_ffmpeg_detect()
|
|
|
|
|
|
|
|
|
|
def run(self) -> None:
|
2025-03-03 09:30:52 -06:00
|
|
|
if self._update_enabled_state():
|
|
|
|
|
self.start_all_ffmpeg()
|
2026-02-12 14:42:08 -06:00
|
|
|
# If recording is enabled at startup, set the grace period timer
|
|
|
|
|
if self.config.record.enabled:
|
2026-07-06 09:28:02 -08:00
|
|
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2023-06-30 08:14:39 -04:00
|
|
|
time.sleep(self.sleeptime)
|
2025-12-15 15:02:03 -06:00
|
|
|
last_restart_time = datetime.now().timestamp()
|
|
|
|
|
|
|
|
|
|
# 1 second watchdog loop
|
|
|
|
|
while not self.stop_event.wait(1):
|
2026-03-06 14:45:39 -06:00
|
|
|
updates = self._check_config_updates()
|
|
|
|
|
|
|
|
|
|
# Handle ffmpeg config changes by restarting all ffmpeg processes
|
|
|
|
|
if "ffmpeg" in updates and self.config.enabled:
|
|
|
|
|
self.logger.debug(
|
|
|
|
|
"FFmpeg config updated for %s, restarting ffmpeg processes",
|
|
|
|
|
self.config.name,
|
|
|
|
|
)
|
|
|
|
|
self.stop_all_ffmpeg()
|
|
|
|
|
self.start_all_ffmpeg()
|
|
|
|
|
self.latest_valid_segment_time = 0
|
|
|
|
|
self.latest_invalid_segment_time = 0
|
|
|
|
|
self.latest_cache_segment_time = 0
|
2026-09-20 13:44:47 -05:00
|
|
|
self.record_grace_until = None
|
2026-07-06 09:28:02 -08:00
|
|
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
2026-03-06 14:45:39 -06:00
|
|
|
last_restart_time = datetime.now().timestamp()
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
enabled = self.config.enabled
|
2025-03-03 09:30:52 -06:00
|
|
|
if enabled != self.was_enabled:
|
|
|
|
|
if enabled:
|
2025-05-22 12:16:51 -06:00
|
|
|
self.logger.debug(f"Enabling camera {self.config.name}")
|
2025-03-03 09:30:52 -06:00
|
|
|
self.start_all_ffmpeg()
|
2025-09-28 11:52:14 -05:00
|
|
|
|
2026-02-12 14:42:08 -06:00
|
|
|
# reset all timestamps and record the enable time for grace period
|
2025-09-28 11:52:14 -05:00
|
|
|
self.latest_valid_segment_time = 0
|
|
|
|
|
self.latest_invalid_segment_time = 0
|
|
|
|
|
self.latest_cache_segment_time = 0
|
2026-09-20 13:44:47 -05:00
|
|
|
self.record_grace_until = None
|
2026-07-06 09:28:02 -08:00
|
|
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
2025-03-03 09:30:52 -06:00
|
|
|
else:
|
2025-05-22 12:16:51 -06:00
|
|
|
self.logger.debug(f"Disabling camera {self.config.name}")
|
2025-03-03 09:30:52 -06:00
|
|
|
self.stop_all_ffmpeg()
|
2026-02-12 14:42:08 -06:00
|
|
|
self.record_enable_time = None
|
2025-08-22 06:42:36 -06:00
|
|
|
|
|
|
|
|
# update camera status
|
2025-12-15 15:02:03 -06:00
|
|
|
now = datetime.now().timestamp()
|
|
|
|
|
self._send_detect_status("disabled", now)
|
|
|
|
|
self._send_record_status("disabled", now)
|
2025-03-03 09:30:52 -06:00
|
|
|
self.was_enabled = enabled
|
|
|
|
|
continue
|
|
|
|
|
|
2026-05-07 08:53:07 -05:00
|
|
|
record_enabled_in_config = self.config.record.enabled_in_config
|
|
|
|
|
if record_enabled_in_config != self.was_record_enabled_in_config:
|
|
|
|
|
if record_enabled_in_config and enabled:
|
|
|
|
|
self.logger.debug(
|
|
|
|
|
f"Record enabled in config for {self.config.name}, restarting ffmpeg"
|
|
|
|
|
)
|
|
|
|
|
self.stop_all_ffmpeg()
|
|
|
|
|
self.start_all_ffmpeg()
|
|
|
|
|
self.latest_valid_segment_time = 0
|
|
|
|
|
self.latest_invalid_segment_time = 0
|
|
|
|
|
self.latest_cache_segment_time = 0
|
2026-09-20 13:44:47 -05:00
|
|
|
self.record_grace_until = None
|
2026-07-06 09:28:02 -08:00
|
|
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
2026-05-07 08:53:07 -05:00
|
|
|
last_restart_time = datetime.now().timestamp()
|
|
|
|
|
self.was_record_enabled_in_config = record_enabled_in_config
|
|
|
|
|
continue
|
|
|
|
|
|
2025-03-03 09:30:52 -06:00
|
|
|
if not enabled:
|
|
|
|
|
continue
|
|
|
|
|
|
2025-09-28 11:52:14 -05:00
|
|
|
while True:
|
|
|
|
|
update = self.segment_subscriber.check_for_update(timeout=0)
|
|
|
|
|
|
|
|
|
|
if update == (None, None):
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
raw_topic, payload = update
|
|
|
|
|
if raw_topic and payload:
|
|
|
|
|
topic = str(raw_topic)
|
|
|
|
|
camera, segment_time, _ = payload
|
|
|
|
|
|
|
|
|
|
if camera != self.config.name:
|
|
|
|
|
continue
|
|
|
|
|
|
2026-04-28 09:54:09 -05:00
|
|
|
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
2025-09-28 11:52:14 -05:00
|
|
|
self.logger.warning(
|
|
|
|
|
f"Invalid recording segment detected for {camera} at {segment_time}"
|
|
|
|
|
)
|
|
|
|
|
self.latest_invalid_segment_time = segment_time
|
2026-04-28 09:54:09 -05:00
|
|
|
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
|
|
|
|
|
self.logger.debug(
|
|
|
|
|
f"Latest valid recording segment time on {camera}: {segment_time}"
|
|
|
|
|
)
|
|
|
|
|
self.latest_valid_segment_time = segment_time
|
2025-09-28 11:52:14 -05:00
|
|
|
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
|
|
|
|
if segment_time is not None:
|
|
|
|
|
self.latest_cache_segment_time = segment_time
|
|
|
|
|
else:
|
|
|
|
|
self.latest_cache_segment_time = 0
|
|
|
|
|
|
|
|
|
|
now = datetime.now().timestamp()
|
2020-10-25 10:05:21 -05:00
|
|
|
|
2025-12-15 15:02:03 -06:00
|
|
|
# Check if enough time has passed to allow ffmpeg restart (backoff pacing)
|
|
|
|
|
time_since_last_restart = now - last_restart_time
|
|
|
|
|
can_restart = time_since_last_restart >= self.sleeptime
|
|
|
|
|
|
2020-10-25 10:05:21 -05:00
|
|
|
if not self.capture_thread.is_alive():
|
2025-12-15 15:02:03 -06:00
|
|
|
self._send_detect_status("offline", now)
|
2022-11-28 20:47:20 -07:00
|
|
|
self.camera_fps.value = 0
|
2021-08-14 14:04:00 -05:00
|
|
|
self.logger.error(
|
2025-05-22 12:16:51 -06:00
|
|
|
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
2021-08-14 14:04:00 -05:00
|
|
|
)
|
2025-12-15 15:02:03 -06:00
|
|
|
if can_restart:
|
|
|
|
|
self.reset_capture_thread(terminate=False)
|
|
|
|
|
last_restart_time = now
|
2023-01-29 16:20:42 -07:00
|
|
|
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
2024-09-10 07:39:37 -06:00
|
|
|
self.fps_overflow_count += 1
|
|
|
|
|
|
|
|
|
|
if self.fps_overflow_count == 3:
|
2025-12-15 15:02:03 -06:00
|
|
|
self._send_detect_status("offline", now)
|
2024-09-10 07:39:37 -06:00
|
|
|
self.fps_overflow_count = 0
|
|
|
|
|
self.camera_fps.value = 0
|
|
|
|
|
self.logger.info(
|
2025-05-22 12:16:51 -06:00
|
|
|
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
|
2024-09-10 07:39:37 -06:00
|
|
|
)
|
2025-12-15 15:02:03 -06:00
|
|
|
if can_restart:
|
|
|
|
|
self.reset_capture_thread(drain_output=False)
|
|
|
|
|
last_restart_time = now
|
2025-07-11 13:19:43 -06:00
|
|
|
elif now - self.capture_thread.current_frame.value > 20:
|
2025-12-15 15:02:03 -06:00
|
|
|
self._send_detect_status("offline", now)
|
2025-07-11 13:19:43 -06:00
|
|
|
self.camera_fps.value = 0
|
|
|
|
|
self.logger.info(
|
2025-05-22 12:16:51 -06:00
|
|
|
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
2025-07-11 13:19:43 -06:00
|
|
|
)
|
2025-12-15 15:02:03 -06:00
|
|
|
if can_restart:
|
|
|
|
|
self.reset_capture_thread()
|
|
|
|
|
last_restart_time = now
|
2024-09-10 07:39:37 -06:00
|
|
|
else:
|
|
|
|
|
# process is running normally
|
2025-12-15 15:02:03 -06:00
|
|
|
self._send_detect_status("online", now)
|
2024-09-10 07:39:37 -06:00
|
|
|
self.fps_overflow_count = 0
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2020-11-29 15:55:53 -06:00
|
|
|
for p in self.ffmpeg_other_processes:
|
2021-02-17 07:23:32 -06:00
|
|
|
poll = p["process"].poll()
|
2022-12-08 20:03:54 -07:00
|
|
|
|
|
|
|
|
if self.config.record.enabled and "record" in p["roles"]:
|
2026-07-06 09:28:02 -08:00
|
|
|
now_utc = datetime.now().astimezone(UTC)
|
2025-09-28 11:52:14 -05:00
|
|
|
|
2026-09-20 13:44:47 -05:00
|
|
|
# ffmpeg needs time to create a first segment after
|
|
|
|
|
# recording is enabled and after a restart
|
|
|
|
|
in_grace_period = (
|
|
|
|
|
self.record_enable_time is not None
|
|
|
|
|
and (now_utc - self.record_enable_time)
|
|
|
|
|
< timedelta(seconds=RECORD_GRACE_SECONDS)
|
|
|
|
|
) or (
|
|
|
|
|
self.record_grace_until is not None
|
|
|
|
|
and now_utc < self.record_grace_until
|
|
|
|
|
)
|
2026-02-12 14:42:08 -06:00
|
|
|
|
2025-09-28 11:52:14 -05:00
|
|
|
latest_cache_dt = (
|
2026-07-06 09:28:02 -08:00
|
|
|
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
|
2025-09-28 11:52:14 -05:00
|
|
|
if self.latest_cache_segment_time > 0
|
|
|
|
|
else now_utc - timedelta(seconds=1)
|
2022-12-08 20:03:54 -07:00
|
|
|
)
|
|
|
|
|
|
2025-09-28 11:52:14 -05:00
|
|
|
latest_valid_dt = (
|
2026-07-06 09:28:02 -08:00
|
|
|
datetime.fromtimestamp(self.latest_valid_segment_time, tz=UTC)
|
2025-09-28 11:52:14 -05:00
|
|
|
if self.latest_valid_segment_time > 0
|
|
|
|
|
else now_utc - timedelta(seconds=1)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
latest_invalid_dt = (
|
2026-07-06 09:28:02 -08:00
|
|
|
datetime.fromtimestamp(self.latest_invalid_segment_time, tz=UTC)
|
2025-09-28 11:52:14 -05:00
|
|
|
if self.latest_invalid_segment_time > 0
|
|
|
|
|
else now_utc - timedelta(seconds=1)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ensure segments are still being created and that they have valid video data
|
2026-02-12 14:42:08 -06:00
|
|
|
# Skip checks during grace period to allow segments to start being created
|
2026-04-29 17:20:19 -05:00
|
|
|
stale_window = timedelta(seconds=self.record_stale_threshold)
|
2026-02-12 14:42:08 -06:00
|
|
|
cache_stale = not in_grace_period and now_utc > (
|
2026-04-29 17:20:19 -05:00
|
|
|
latest_cache_dt + stale_window
|
2026-02-12 14:42:08 -06:00
|
|
|
)
|
|
|
|
|
valid_stale = not in_grace_period and now_utc > (
|
2026-04-29 17:20:19 -05:00
|
|
|
latest_valid_dt + stale_window
|
2026-02-12 14:42:08 -06:00
|
|
|
)
|
2025-09-28 11:52:14 -05:00
|
|
|
invalid_stale_condition = (
|
|
|
|
|
self.latest_invalid_segment_time > 0
|
2026-02-12 14:42:08 -06:00
|
|
|
and not in_grace_period
|
2026-04-29 17:20:19 -05:00
|
|
|
and now_utc > (latest_invalid_dt + stale_window)
|
2025-09-28 11:52:14 -05:00
|
|
|
and self.latest_valid_segment_time
|
|
|
|
|
<= self.latest_invalid_segment_time
|
|
|
|
|
)
|
|
|
|
|
invalid_stale = invalid_stale_condition
|
2026-09-20 13:44:47 -05:00
|
|
|
stale = cache_stale or valid_stale or invalid_stale
|
2025-09-28 11:52:14 -05:00
|
|
|
|
2026-09-20 13:44:47 -05:00
|
|
|
if stale and can_restart:
|
2025-09-28 11:52:14 -05:00
|
|
|
if cache_stale:
|
|
|
|
|
reason = "No new recording segments were created"
|
|
|
|
|
elif valid_stale:
|
|
|
|
|
reason = "No new valid recording segments were created"
|
|
|
|
|
else: # invalid_stale
|
|
|
|
|
reason = (
|
|
|
|
|
"No valid segments created since last invalid segment"
|
|
|
|
|
)
|
|
|
|
|
|
2022-12-08 20:03:54 -07:00
|
|
|
self.logger.error(
|
2026-04-29 17:20:19 -05:00
|
|
|
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
|
2022-12-08 20:03:54 -07:00
|
|
|
)
|
|
|
|
|
p["process"] = start_or_restart_ffmpeg(
|
|
|
|
|
p["cmd"],
|
|
|
|
|
self.logger,
|
|
|
|
|
p["logpipe"],
|
|
|
|
|
ffmpeg_process=p["process"],
|
|
|
|
|
)
|
2025-08-22 06:42:36 -06:00
|
|
|
|
2026-10-01 09:56:27 -05:00
|
|
|
self._send_roles_offline(p["roles"], now)
|
2025-08-22 06:42:36 -06:00
|
|
|
|
2026-09-20 13:44:47 -05:00
|
|
|
self.record_grace_until = now_utc + timedelta(
|
|
|
|
|
seconds=RECORD_GRACE_SECONDS
|
|
|
|
|
)
|
|
|
|
|
last_restart_time = now
|
|
|
|
|
|
2022-12-08 20:03:54 -07:00
|
|
|
continue
|
2026-09-20 13:44:47 -05:00
|
|
|
elif not stale:
|
2026-10-01 09:56:27 -05:00
|
|
|
if poll is None:
|
|
|
|
|
self._send_record_status("online", now)
|
2025-09-28 11:52:14 -05:00
|
|
|
p["latest_segment_time"] = self.latest_cache_segment_time
|
2022-12-08 20:03:54 -07:00
|
|
|
|
2021-06-25 10:37:21 -06:00
|
|
|
if poll is None:
|
2020-11-29 15:55:53 -06:00
|
|
|
continue
|
2022-12-08 20:03:54 -07:00
|
|
|
|
2026-10-01 09:56:27 -05:00
|
|
|
self._send_roles_offline(p["roles"], now)
|
2025-08-22 06:42:36 -06:00
|
|
|
|
2021-02-17 07:23:32 -06:00
|
|
|
p["logpipe"].dump()
|
|
|
|
|
p["process"] = start_or_restart_ffmpeg(
|
|
|
|
|
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-06 08:53:23 -05:00
|
|
|
# Prune expired reconnect timestamps
|
2025-12-15 15:02:03 -06:00
|
|
|
now = datetime.now().timestamp()
|
2026-04-06 08:53:23 -05:00
|
|
|
while (
|
|
|
|
|
self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600
|
|
|
|
|
):
|
|
|
|
|
self.reconnect_timestamps.popleft()
|
|
|
|
|
if self.reconnects:
|
|
|
|
|
self.reconnects.value = len(self.reconnect_timestamps)
|
|
|
|
|
|
|
|
|
|
# Update stall metrics based on last processed frame timestamp
|
2025-12-15 15:02:03 -06:00
|
|
|
processed_ts = (
|
|
|
|
|
float(self.detection_frame.value) if self.detection_frame else 0.0
|
|
|
|
|
)
|
|
|
|
|
if processed_ts > 0:
|
|
|
|
|
delta = now - processed_ts
|
|
|
|
|
observed_fps = (
|
|
|
|
|
self.camera_fps.value
|
|
|
|
|
if self.camera_fps.value > 0
|
|
|
|
|
else self.config.detect.fps
|
|
|
|
|
)
|
|
|
|
|
interval = 1.0 / max(observed_fps, 0.1)
|
|
|
|
|
stall_threshold = max(2.0 * interval, 2.0)
|
|
|
|
|
|
|
|
|
|
if delta > stall_threshold:
|
|
|
|
|
if not self._stall_active:
|
|
|
|
|
self._stall_timestamps.append(now)
|
|
|
|
|
self._stall_active = True
|
|
|
|
|
else:
|
|
|
|
|
self._stall_active = False
|
|
|
|
|
|
|
|
|
|
while self._stall_timestamps and self._stall_timestamps[0] < now - 3600:
|
|
|
|
|
self._stall_timestamps.popleft()
|
|
|
|
|
|
|
|
|
|
if self.stalls:
|
|
|
|
|
self.stalls.value = len(self._stall_timestamps)
|
|
|
|
|
|
2025-03-03 09:30:52 -06:00
|
|
|
self.stop_all_ffmpeg()
|
2021-05-21 11:39:14 -04:00
|
|
|
self.logpipe.close()
|
2025-03-03 09:30:52 -06:00
|
|
|
self.config_subscriber.stop()
|
2025-09-28 11:52:14 -05:00
|
|
|
self.segment_subscriber.stop()
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2020-11-29 15:55:53 -06:00
|
|
|
def start_ffmpeg_detect(self):
|
2021-02-17 07:23:32 -06:00
|
|
|
ffmpeg_cmd = [
|
|
|
|
|
c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"]
|
|
|
|
|
][0]
|
|
|
|
|
self.ffmpeg_detect_process = start_or_restart_ffmpeg(
|
|
|
|
|
ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
|
|
|
|
|
)
|
2020-11-29 15:55:53 -06:00
|
|
|
self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid
|
2025-06-12 12:12:34 -06:00
|
|
|
self.capture_thread = CameraCaptureRunner(
|
2024-09-03 10:22:30 -06:00
|
|
|
self.config,
|
|
|
|
|
self.shm_frame_count,
|
2024-11-17 10:25:49 -07:00
|
|
|
self.frame_index,
|
2021-02-17 07:23:32 -06:00
|
|
|
self.ffmpeg_detect_process,
|
|
|
|
|
self.frame_shape,
|
|
|
|
|
self.frame_queue,
|
|
|
|
|
self.camera_fps,
|
2023-06-28 04:53:28 -06:00
|
|
|
self.skipped_fps,
|
2023-02-04 08:58:45 -06:00
|
|
|
self.stop_event,
|
2021-02-17 07:23:32 -06:00
|
|
|
)
|
2020-11-01 10:55:11 -06:00
|
|
|
self.capture_thread.start()
|
2020-10-25 10:05:21 -05:00
|
|
|
|
2025-03-03 09:30:52 -06:00
|
|
|
def start_all_ffmpeg(self):
|
|
|
|
|
"""Start all ffmpeg processes (detection and others)."""
|
2025-05-22 12:16:51 -06:00
|
|
|
logger.debug(f"Starting all ffmpeg processes for {self.config.name}")
|
2025-03-03 09:30:52 -06:00
|
|
|
self.start_ffmpeg_detect()
|
|
|
|
|
for c in self.config.ffmpeg_cmds:
|
|
|
|
|
if "detect" in c["roles"]:
|
|
|
|
|
continue
|
|
|
|
|
logpipe = LogPipe(
|
2025-05-22 12:16:51 -06:00
|
|
|
f"ffmpeg.{self.config.name}.{'_'.join(sorted(c['roles']))}"
|
2025-03-03 09:30:52 -06:00
|
|
|
)
|
|
|
|
|
self.ffmpeg_other_processes.append(
|
|
|
|
|
{
|
|
|
|
|
"cmd": c["cmd"],
|
|
|
|
|
"roles": c["roles"],
|
|
|
|
|
"logpipe": logpipe,
|
|
|
|
|
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def stop_all_ffmpeg(self):
|
|
|
|
|
"""Stop all ffmpeg processes (detection and others)."""
|
2025-05-22 12:16:51 -06:00
|
|
|
logger.debug(f"Stopping all ffmpeg processes for {self.config.name}")
|
2025-03-03 09:30:52 -06:00
|
|
|
if self.capture_thread is not None and self.capture_thread.is_alive():
|
|
|
|
|
self.capture_thread.join(timeout=5)
|
|
|
|
|
if self.capture_thread.is_alive():
|
|
|
|
|
self.logger.warning(
|
2025-05-22 12:16:51 -06:00
|
|
|
f"Capture thread for {self.config.name} did not stop gracefully."
|
2025-03-03 09:30:52 -06:00
|
|
|
)
|
|
|
|
|
if self.ffmpeg_detect_process is not None:
|
|
|
|
|
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
|
|
|
|
|
self.ffmpeg_detect_process = None
|
|
|
|
|
for p in self.ffmpeg_other_processes[:]:
|
|
|
|
|
if p["process"] is not None:
|
|
|
|
|
stop_ffmpeg(p["process"], self.logger)
|
|
|
|
|
p["logpipe"].close()
|
|
|
|
|
self.ffmpeg_other_processes.clear()
|
|
|
|
|
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2025-06-12 12:12:34 -06:00
|
|
|
class CameraCaptureRunner(threading.Thread):
|
2023-02-04 08:58:45 -06:00
|
|
|
def __init__(
|
2023-06-28 04:53:28 -06:00
|
|
|
self,
|
2024-09-03 10:22:30 -06:00
|
|
|
config: CameraConfig,
|
|
|
|
|
shm_frame_count: int,
|
2024-11-17 10:25:49 -07:00
|
|
|
frame_index: int,
|
2023-06-28 04:53:28 -06:00
|
|
|
ffmpeg_process,
|
2024-11-17 10:25:49 -07:00
|
|
|
frame_shape: tuple[int, int],
|
2025-04-23 17:06:06 -06:00
|
|
|
frame_queue: Queue,
|
|
|
|
|
fps: Value,
|
|
|
|
|
skipped_fps: Value,
|
|
|
|
|
stop_event: MpEvent,
|
2023-02-04 08:58:45 -06:00
|
|
|
):
|
2020-03-14 15:32:51 -05:00
|
|
|
threading.Thread.__init__(self)
|
2024-09-03 10:22:30 -06:00
|
|
|
self.name = f"capture:{config.name}"
|
|
|
|
|
self.config = config
|
|
|
|
|
self.shm_frame_count = shm_frame_count
|
2024-11-17 10:25:49 -07:00
|
|
|
self.frame_index = frame_index
|
2020-03-14 15:32:51 -05:00
|
|
|
self.frame_shape = frame_shape
|
|
|
|
|
self.frame_queue = frame_queue
|
|
|
|
|
self.fps = fps
|
2023-02-04 08:58:45 -06:00
|
|
|
self.stop_event = stop_event
|
2023-06-28 04:53:28 -06:00
|
|
|
self.skipped_fps = skipped_fps
|
2020-09-21 21:02:00 -05:00
|
|
|
self.frame_manager = SharedMemoryFrameManager()
|
2020-03-14 15:32:51 -05:00
|
|
|
self.ffmpeg_process = ffmpeg_process
|
2025-04-23 17:06:06 -06:00
|
|
|
self.current_frame = Value("d", 0.0)
|
2020-04-19 10:07:27 -05:00
|
|
|
self.last_frame = 0
|
2020-03-14 15:32:51 -05:00
|
|
|
|
|
|
|
|
def run(self):
|
2021-02-17 07:23:32 -06:00
|
|
|
capture_frames(
|
|
|
|
|
self.ffmpeg_process,
|
2024-09-03 10:22:30 -06:00
|
|
|
self.config,
|
|
|
|
|
self.shm_frame_count,
|
2024-11-17 10:25:49 -07:00
|
|
|
self.frame_index,
|
2021-02-17 07:23:32 -06:00
|
|
|
self.frame_shape,
|
|
|
|
|
self.frame_manager,
|
|
|
|
|
self.frame_queue,
|
|
|
|
|
self.fps,
|
|
|
|
|
self.skipped_fps,
|
|
|
|
|
self.current_frame,
|
2023-02-04 08:58:45 -06:00
|
|
|
self.stop_event,
|
2021-02-17 07:23:32 -06:00
|
|
|
)
|
|
|
|
|
|
2020-08-02 08:46:36 -05:00
|
|
|
|
2025-06-13 11:09:51 -06:00
|
|
|
class CameraCapture(FrigateProcess):
|
2025-06-12 12:12:34 -06:00
|
|
|
def __init__(
|
2025-06-24 11:41:11 -06:00
|
|
|
self,
|
|
|
|
|
config: CameraConfig,
|
|
|
|
|
shm_frame_count: int,
|
|
|
|
|
camera_metrics: CameraMetrics,
|
|
|
|
|
stop_event: MpEvent,
|
2025-11-17 08:12:05 -06:00
|
|
|
log_config: LoggerConfig | None = None,
|
2025-06-12 12:12:34 -06:00
|
|
|
) -> None:
|
2025-07-18 11:23:06 -06:00
|
|
|
super().__init__(
|
|
|
|
|
stop_event,
|
|
|
|
|
PROCESS_PRIORITY_HIGH,
|
|
|
|
|
name=f"frigate.capture:{config.name}",
|
|
|
|
|
daemon=True,
|
|
|
|
|
)
|
2025-06-12 12:12:34 -06:00
|
|
|
self.config = config
|
|
|
|
|
self.shm_frame_count = shm_frame_count
|
|
|
|
|
self.camera_metrics = camera_metrics
|
2025-11-17 08:12:05 -06:00
|
|
|
self.log_config = log_config
|
2021-02-17 07:23:32 -06:00
|
|
|
|
2025-06-12 12:12:34 -06:00
|
|
|
def run(self) -> None:
|
2025-11-17 08:12:05 -06:00
|
|
|
self.pre_run_setup(self.log_config)
|
2025-06-12 12:12:34 -06:00
|
|
|
camera_watchdog = CameraWatchdog(
|
|
|
|
|
self.config,
|
|
|
|
|
self.shm_frame_count,
|
|
|
|
|
self.camera_metrics.frame_queue,
|
|
|
|
|
self.camera_metrics.camera_fps,
|
|
|
|
|
self.camera_metrics.skipped_fps,
|
|
|
|
|
self.camera_metrics.ffmpeg_pid,
|
2025-12-15 15:02:03 -06:00
|
|
|
self.camera_metrics.stalls_last_hour,
|
|
|
|
|
self.camera_metrics.reconnects_last_hour,
|
|
|
|
|
self.camera_metrics.detection_frame,
|
2025-06-12 12:12:34 -06:00
|
|
|
self.stop_event,
|
|
|
|
|
)
|
|
|
|
|
camera_watchdog.start()
|
|
|
|
|
camera_watchdog.join()
|