diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 9e11a0af1..316813518 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -43,6 +43,11 @@ class MqttClient(Communicator): # type: ignore[misc] def _set_initial_topics(self) -> None: """Set initial state topics.""" for camera_name, camera in self.config.cameras.items(): + self.publish( + f"{camera_name}/enabled/state", + "ON" if camera.enabled_in_config else "OFF", + retain=True, + ) self.publish( f"{camera_name}/recordings/state", "ON" if camera.record.enabled_in_config else "OFF", @@ -196,6 +201,7 @@ class MqttClient(Communicator): # type: ignore[misc] # register callbacks callback_types = [ + "enabled", "recordings", "snapshots", "detect", diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 3d036e9d5..cd4aa26ec 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -390,8 +390,11 @@ class BirdsEyeFrameManager: def _get_enabled_state(self, camera: str) -> bool: """Fetch the latest enabled state for a camera from ZMQ.""" _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + self.config.cameras[camera].enabled = config_data.enabled return config_data.enabled + return self.config.cameras[camera].enabled def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: @@ -704,15 +707,17 @@ class BirdsEyeFrameManager: ) -> bool: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye + force_update = False # disabling birdseye is a little tricky - if not camera_config.enabled or not self._get_enabled_state(camera): + if not self._get_enabled_state(camera): # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: self.cameras[camera]["last_active_frame"] = 0 - - return False + force_update = True + else: + return False # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() @@ -723,7 +728,7 @@ class BirdsEyeFrameManager: now = datetime.datetime.now().timestamp() # limit output to 10 fps - if (now - self.last_output_time) < 1 / 10: + if not force_update and (now - self.last_output_time) < 1 / 10: return False try: @@ -735,7 +740,7 @@ class BirdsEyeFrameManager: print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame - if updated_frame or (now - self.last_output_time) > 1: + if force_update or updated_frame or (now - self.last_output_time) > 1: self.last_output_time = now return True return False @@ -783,6 +788,22 @@ class Birdseye: self.converter.start() self.broadcaster.start() + def __send_new_frame(self) -> None: + frame_bytes = self.birdseye_manager.frame.tobytes() + + if self.config.birdseye.restream: + self.birdseye_buffer[:] = frame_bytes + + try: + self.input.put_nowait(frame_bytes) + except queue.Full: + # drop frames if queue is full + pass + + def all_cameras_disabled(self) -> None: + self.birdseye_manager.clear_frame() + self.__send_new_frame() + def write_data( self, camera: str, @@ -811,16 +832,7 @@ class Birdseye: frame_time, frame, ): - frame_bytes = self.birdseye_manager.frame.tobytes() - - if self.config.birdseye.restream: - self.birdseye_buffer[:] = frame_bytes - - try: - self.input.put_nowait(frame_bytes) - except queue.Full: - # drop frames if queue is full - pass + self.__send_new_frame() def stop(self) -> None: self.config_subscriber.stop() diff --git a/frigate/output/output.py b/frigate/output/output.py index 9beb87250..e0e64e298 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -1,12 +1,12 @@ """Handle outputting raw frigate frames""" +import datetime import logging import multiprocessing as mp import os import shutil import signal import threading -from typing import Optional from wsgiref.simple_server import make_server from setproctitle import setproctitle @@ -25,11 +25,43 @@ from frigate.const import CACHE_DIR, CLIPS_DIR from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder -from frigate.util.image import SharedMemoryFrameManager +from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame logger = logging.getLogger(__name__) +def check_disabled_camera_update( + config: FrigateConfig, + birdseye: Birdseye | None, + previews: dict[str, PreviewRecorder], + write_times: dict[str, float], +) -> None: + """Check if camera is disabled / offline and needs an update.""" + now = datetime.datetime.now().timestamp() + has_enabled_camera = False + + for camera, last_update in write_times.items(): + if config.cameras[camera].enabled: + has_enabled_camera = True + + if now - last_update > 1: + # last camera update was more than one second ago + # need to send empty data to updaters because current + # frame is now out of date + frame = get_blank_yuv_frame( + config.cameras[camera].detect.width, + config.cameras[camera].detect.height, + ) + + if birdseye: + birdseye.write_data(camera, [], [], now, frame) + + previews[camera].write_data([], [], now, frame) + + if not has_enabled_camera and birdseye: + birdseye.all_cameras_disabled() + + def output_frames( config: FrigateConfig, ): @@ -67,10 +99,11 @@ def output_frames( } jsmpeg_cameras: dict[str, JsmpegCamera] = {} - birdseye: Optional[Birdseye] = None + birdseye: Birdseye | None = None preview_recorders: dict[str, PreviewRecorder] = {} preview_write_times: dict[str, float] = {} failed_frame_requests: dict[str, int] = {} + last_disabled_cam_check = datetime.datetime.now().timestamp() move_preview_frames("cache") @@ -89,13 +122,23 @@ def output_frames( def get_enabled_state(camera: str) -> bool: _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + config.cameras[camera].enabled = config_data.enabled return config_data.enabled - # default + return config.cameras[camera].enabled while not stop_event.is_set(): (topic, data) = detection_subscriber.check_for_update(timeout=1) + now = datetime.datetime.now().timestamp() + + if now - last_disabled_cam_check > 5: + # check disabled cameras every 5 seconds + last_disabled_cam_check = now + check_disabled_camera_update( + config, birdseye, preview_recorders, preview_write_times + ) if not topic: continue @@ -151,23 +194,10 @@ def output_frames( ) # send frames for low fps recording - generated_preview = preview_recorders[camera].write_data( + preview_recorders[camera].write_data( current_tracked_objects, motion_boxes, frame_time, frame ) preview_write_times[camera] = frame_time - - # if another camera generated a preview, - # check for any cameras that are currently offline - # and need to generate a preview - if generated_preview: - logger.debug( - "Checking for offline cameras because another camera generated a preview." - ) - for camera, time in preview_write_times.copy().items(): - if time != 0 and frame_time - time > 10: - preview_recorders[camera].flag_offline(frame_time) - preview_write_times[camera] = frame_time - frame_manager.close(frame_name) move_preview_frames("clips") diff --git a/frigate/util/image.py b/frigate/util/image.py index 7e4915821..20806372c 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -632,6 +632,22 @@ def copy_yuv_to_position( ) +def get_blank_yuv_frame(width: int, height: int) -> np.ndarray: + """Creates a black YUV 4:2:0 frame.""" + yuv_height = height * 3 // 2 + yuv_frame = np.zeros((yuv_height, width), dtype=np.uint8) + + uv_height = height // 2 + + # The U and V planes are stored after the Y plane. + u_start = height # U plane starts right after Y plane + v_start = u_start + uv_height // 2 # V plane starts after U plane + yuv_frame[u_start : u_start + uv_height, :width] = 128 + yuv_frame[v_start : v_start + uv_height, :width] = 128 + + return yuv_frame + + def yuv_region_2_yuv(frame, region): try: # TODO: does this copy the numpy array? diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index f2b0639a4..913373774 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -200,7 +200,7 @@ export default function LivePlayer({ // enabled states const [isReEnabling, setIsReEnabling] = useState(false); - const prevCameraEnabledRef = useRef(cameraEnabled); + const prevCameraEnabledRef = useRef(cameraEnabled ?? true); useEffect(() => { if (!prevCameraEnabledRef.current && cameraEnabled) { diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 3b85de4b3..d0da3e5ac 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -396,10 +396,12 @@ export default function DraggableGridLayout({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 45d0d5302..e59fd96ca 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -268,10 +268,12 @@ export default function LiveDashboardView({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates);