diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 4eaf61919..fc8888e40 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera. **NOTE:** Requires audio detection to be enabled +### `frigate//enabled/set` + +Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//enabled/state` + +Topic with current state of processing for a camera. Published values are `ON` and `OFF`. + ### `frigate//detect/set` Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index a6e40f4ca..7f6354641 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -20,7 +20,7 @@ class CameraActivityManager: self.all_zone_labels: dict[str, set[str]] = {} for camera_config in config.cameras.values(): - if not camera_config.enabled: + if not camera_config.enabled_in_config: continue self.last_camera_activity[camera_config.name] = {} diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 61530d086..586b70cbb 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -55,6 +55,7 @@ class Dispatcher: self._camera_settings_handlers: dict[str, Callable] = { "audio": self._on_audio_command, "detect": self._on_detect_command, + "enabled": self._on_enabled_command, "improve_contrast": self._on_motion_improve_contrast_command, "ptz_autotracker": self._on_ptz_autotracker_command, "motion": self._on_motion_command, @@ -167,6 +168,7 @@ class Dispatcher: for camera in camera_status.keys(): camera_status[camera]["config"] = { "detect": self.config.cameras[camera].detect.enabled, + "enabled": self.config.cameras[camera].enabled, "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, @@ -278,6 +280,27 @@ class Dispatcher: self.config_updater.publish(f"config/detect/{camera_name}", detect_settings) self.publish(f"{camera_name}/detect/state", payload, retain=True) + def _on_enabled_command(self, camera_name: str, payload: str) -> None: + """Callback for camera topic.""" + camera_settings = self.config.cameras[camera_name] + + if payload == "ON": + if not self.config.cameras[camera_name].enabled_in_config: + logger.error( + "Camera must be enabled in the config to be turned on via MQTT." + ) + return + if not camera_settings.enabled: + logger.info(f"Turning on camera {camera_name}") + camera_settings.enabled = True + elif payload == "OFF": + if camera_settings.enabled: + logger.info(f"Turning off camera {camera_name}") + camera_settings.enabled = False + + self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings) + self.publish(f"{camera_name}/enabled/state", payload, retain=True) + def _on_motion_command(self, camera_name: str, payload: str) -> None: """Callback for motion topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 50f61f33c..2d928661e 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel): zones: dict[str, ZoneConfig] = Field( default_factory=dict, title="Zone configuration." ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of camera." + ) _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr() diff --git a/frigate/config/config.py b/frigate/config/config.py index d2ca9a6f5..633aef803 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel): camera_config.detect.stationary.interval = stationary_threshold # set config pre-value + camera_config.enabled_in_config = camera_config.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.notifications.enabled_in_config = ( diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 137883b2b..783c2b2d0 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -10,6 +10,7 @@ from typing import Callable, Optional import cv2 import numpy as np +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher @@ -61,6 +62,7 @@ class CameraState: self.previous_frame_id = None self.callbacks = defaultdict(list) self.ptz_autotracker_thread = ptz_autotracker_thread + self.prev_enabled = self.camera_config.enabled def get_current_frame(self, draw_options={}): with self.current_frame_lock: @@ -310,6 +312,7 @@ class CameraState: # TODO: can i switch to looking this up and only changing when an event ends? # maintain best objects camera_activity: dict[str, list[any]] = { + "enabled": True, "motion": len(motion_boxes) > 0, "objects": [], } @@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread + self.enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + } + self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() @@ -679,8 +687,55 @@ class TrackedObjectProcessor(threading.Thread): """Returns the latest frame time for a given camera.""" return self.camera_states[camera].current_frame_time + def force_end_all_events(self, camera: str, camera_state: CameraState): + """Ends all active events on camera when disabling.""" + last_frame_name = camera_state.previous_frame_id + for obj_id, obj in list(camera_state.tracked_objects.items()): + if "end_time" not in obj.obj_data: + logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") + obj.obj_data["end_time"] = datetime.datetime.now().timestamp() + # end callbacks + for callback in camera_state.callbacks["end"]: + callback(camera, obj, last_frame_name) + + # camera activity callbacks + for callback in camera_state.callbacks["camera_activity"]: + callback( + camera, + {"enabled": False, "motion": 0, "objects": []}, + ) + + def _get_enabled_state(self, camera: str) -> bool: + _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + enabled = config_data.enabled + if self.camera_states[camera].prev_enabled is None: + self.camera_states[camera].prev_enabled = enabled + return enabled + return ( + self.camera_states[camera].prev_enabled + if self.camera_states[camera].prev_enabled is not None + else self.config.cameras[camera].enabled + ) + def run(self): while not self.stop_event.is_set(): + for camera, config in self.config.cameras.items(): + if not config.enabled_in_config: + continue + + current_enabled = self._get_enabled_state(camera) + camera_state = self.camera_states[camera] + + if camera_state.prev_enabled and not current_enabled: + logger.debug(f"Not processing objects for disabled camera {camera}") + self.force_end_all_events(camera, camera_state) + + camera_state.prev_enabled = current_enabled + + if not current_enabled: + continue + try: ( camera, @@ -693,6 +748,10 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue + if not self._get_enabled_state(camera): + logger.debug(f"Camera {camera} disabled, skipping update") + continue + camera_state = self.camera_states[camera] camera_state.update( @@ -735,4 +794,7 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + logger.info("Exiting object processor...") diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8331eb64a..3d036e9d5 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -10,6 +10,7 @@ import queue import subprocess as sp import threading import traceback +from typing import Optional import cv2 import numpy as np @@ -280,6 +281,12 @@ class BirdsEyeFrameManager: self.stop_event = stop_event self.inactivity_threshold = config.birdseye.inactivity_threshold + self.enabled_subscribers = { + cam: ConfigSubscriber(f"config/enabled/{cam}", True) + for cam in config.cameras.keys() + if config.cameras[cam].enabled_in_config + } + if config.birdseye.layout.max_cameras: self.last_refresh_time = 0 @@ -380,8 +387,18 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def update_frame(self, frame: np.ndarray): - """Update to a new frame for birdseye.""" + 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: + return config_data.enabled + return self.config.cameras[camera].enabled + + def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: + """ + Update birdseye, optionally with a new frame. + When no frame is passed, check the layout and update for any disabled cameras. + """ # determine how many cameras are tracking objects within the last inactivity_threshold seconds active_cameras: set[str] = set( @@ -389,11 +406,14 @@ class BirdsEyeFrameManager: cam for cam, cam_data in self.cameras.items() if self.config.cameras[cam].birdseye.enabled + and self.config.cameras[cam].enabled_in_config + and self._get_enabled_state(cam) and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] < self.inactivity_threshold ] ) + logger.debug(f"Active cameras: {active_cameras}") max_cameras = self.config.birdseye.layout.max_cameras max_camera_refresh = False @@ -411,118 +431,125 @@ class BirdsEyeFrameManager: - self.cameras[active_camera]["last_active_frame"] ), ) - active_cameras = limited_active_cameras[ - : self.config.birdseye.layout.max_cameras - ] + active_cameras = limited_active_cameras[:max_cameras] max_camera_refresh = True self.last_refresh_time = now - # if there are no active cameras + # Track if the frame changes + frame_changed = False + + # If no active cameras and layout is already empty, no update needed if len(active_cameras) == 0: # if the layout is already cleared if len(self.camera_layout) == 0: return False # if the layout needs to be cleared - else: - self.camera_layout = [] - self.active_cameras = set() - self.clear_frame() - return True - - # check if we need to reset the layout because there is a different number of cameras - if len(self.active_cameras) - len(active_cameras) == 0: - if len(self.active_cameras) == 1 and self.active_cameras != active_cameras: - reset_layout = True - elif max_camera_refresh: - reset_layout = True - else: - reset_layout = False - else: - reset_layout = True - - # reset the layout if it needs to be different - if reset_layout: - logger.debug("Added new cameras, resetting layout...") + self.camera_layout = [] + self.active_cameras = set() self.clear_frame() - self.active_cameras = active_cameras - - # this also converts added_cameras from a set to a list since we need - # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) - - if len(active_cameras) == 1: - # show single camera as fullscreen - camera = active_cameras_to_add[0] - camera_dims = self.cameras[camera]["dimensions"].copy() - scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1]) - - # center camera view in canvas and ensure that it fits - if scaled_width < self.canvas.width: - coefficient = 1 - x_offset = int((self.canvas.width - scaled_width) / 2) + frame_changed = True + else: + # Determine if layout needs resetting + if len(self.active_cameras) - len(active_cameras) == 0: + if ( + len(self.active_cameras) == 1 + and self.active_cameras != active_cameras + ): + reset_layout = True + elif max_camera_refresh: + reset_layout = True else: - coefficient = self.canvas.width / scaled_width - x_offset = int( - (self.canvas.width - (scaled_width * coefficient)) / 2 - ) - - self.camera_layout = [ - [ - ( - camera, - ( - x_offset, - 0, - int(scaled_width * coefficient), - int(self.canvas.height * coefficient), - ), - ) - ] - ] + reset_layout = False else: - # calculate optimal layout - coefficient = self.canvas.get_coefficient(len(active_cameras)) - calculating = True + reset_layout = True - # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas - while calculating: - if self.stop_event.is_set(): - return + if reset_layout: + logger.debug("Resetting Birdseye layout...") + self.clear_frame() + self.active_cameras = active_cameras - layout_candidate = self.calculate_layout( - active_cameras_to_add, - coefficient, + # this also converts added_cameras from a set to a list since we need + # to pop elements in order + active_cameras_to_add = sorted( + active_cameras, + # sort cameras by order and by name if the order is the same + key=lambda active_camera: ( + self.config.cameras[active_camera].birdseye.order, + active_camera, + ), + ) + if len(active_cameras) == 1: + # show single camera as fullscreen + camera = active_cameras_to_add[0] + camera_dims = self.cameras[camera]["dimensions"].copy() + scaled_width = int( + self.canvas.height * camera_dims[0] / camera_dims[1] ) - if not layout_candidate: - if coefficient < 10: - coefficient += 1 - continue - else: - logger.error("Error finding appropriate birdseye layout") + # center camera view in canvas and ensure that it fits + if scaled_width < self.canvas.width: + coefficient = 1 + x_offset = int((self.canvas.width - scaled_width) / 2) + else: + coefficient = self.canvas.width / scaled_width + x_offset = int( + (self.canvas.width - (scaled_width * coefficient)) / 2 + ) + + self.camera_layout = [ + [ + ( + camera, + ( + x_offset, + 0, + int(scaled_width * coefficient), + int(self.canvas.height * coefficient), + ), + ) + ] + ] + else: + # calculate optimal layout + coefficient = self.canvas.get_coefficient(len(active_cameras)) + calculating = True + + # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas + while calculating: + if self.stop_event.is_set(): return - calculating = False - self.canvas.set_coefficient(len(active_cameras), coefficient) + layout_candidate = self.calculate_layout( + active_cameras_to_add, coefficient + ) - self.camera_layout = layout_candidate + if not layout_candidate: + if coefficient < 10: + coefficient += 1 + continue + else: + logger.error( + "Error finding appropriate birdseye layout" + ) + return + calculating = False + self.canvas.set_coefficient(len(active_cameras), coefficient) - for row in self.camera_layout: - for position in row: - self.copy_to_position( - position[1], - position[0], - self.cameras[position[0]]["current_frame"], - ) + self.camera_layout = layout_candidate + frame_changed = True - return True + # Draw the layout + for row in self.camera_layout: + for position in row: + src_frame = self.cameras[position[0]]["current_frame"] + if src_frame is None or src_frame.size == 0: + logger.debug(f"Skipping invalid frame for {position[0]}") + continue + self.copy_to_position(position[1], position[0], src_frame) + if frame is not None: # Frame presence indicates a potential change + frame_changed = True + + return frame_changed def calculate_layout( self, @@ -678,11 +705,8 @@ class BirdsEyeFrameManager: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye - if not camera_config.enabled: - return False - # disabling birdseye is a little tricky - if not camera_config.enabled: + if not camera_config.enabled or 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: @@ -716,6 +740,11 @@ class BirdsEyeFrameManager: return True return False + def stop(self): + """Clean up subscribers when stopping.""" + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + class Birdseye: def __init__( @@ -743,6 +772,7 @@ class Birdseye: self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) self.config_subscriber = ConfigSubscriber("config/birdseye/") self.frame_manager = SharedMemoryFrameManager() + self.stop_event = stop_event if config.birdseye.restream: self.birdseye_buffer = self.frame_manager.create( @@ -794,5 +824,6 @@ class Birdseye: def stop(self) -> None: self.config_subscriber.stop() + self.birdseye_manager.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/output/output.py b/frigate/output/output.py index bb2d73511..9beb87250 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import ( ) from ws4py.server.wsgiutils import WebSocketWSGIApplication +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.ws import WebSocket from frigate.config import FrigateConfig @@ -59,6 +60,12 @@ def output_frames( detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) + enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + if config.cameras[camera].enabled_in_config + } + jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Optional[Birdseye] = None preview_recorders: dict[str, PreviewRecorder] = {} @@ -80,6 +87,13 @@ def output_frames( websocket_thread.start() + def get_enabled_state(camera: str) -> bool: + _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + 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) @@ -95,6 +109,9 @@ def output_frames( _, ) = data + if not get_enabled_state(camera): + continue + frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) if frame is None: @@ -184,6 +201,9 @@ def output_frames( if birdseye is not None: birdseye.stop() + for subscriber in enabled_subscribers.values(): + subscriber.stop() + websocket_server.manager.close_all() websocket_server.manager.stop() websocket_server.manager.join() diff --git a/frigate/video.py b/frigate/video.py index 233cebb9e..69f6c1bfa 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -108,8 +108,20 @@ def capture_frames( frame_rate.start() skipped_eps = EventsPerSecond() skipped_eps.start() + config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True) + + def get_enabled_state(): + """Fetch the latest enabled state from ZMQ.""" + _, config_data = config_subscriber.check_for_update() + if config_data: + return config_data.enabled + return config.enabled + + while not stop_event.is_set(): + if not get_enabled_state(): + logger.debug(f"Stopping capture thread for disabled {config.name}") + break - while True: fps.value = frame_rate.eps() skipped_fps.value = skipped_eps.eps() current_frame.value = datetime.datetime.now().timestamp() @@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread): self.stop_event = stop_event self.sleeptime = self.config.ffmpeg.retry_interval - def run(self): - self.start_ffmpeg_detect() + self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) + self.was_enabled = self.config.enabled - for c in self.config.ffmpeg_cmds: - if "detect" in c["roles"]: - continue - logpipe = LogPipe( - f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" - ) - self.ffmpeg_other_processes.append( - { - "cmd": c["cmd"], - "roles": c["roles"], - "logpipe": logpipe, - "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), - } - ) + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + _, config_data = self.config_subscriber.check_for_update() + if config_data: + enabled = config_data.enabled + return enabled + return self.was_enabled if self.was_enabled is not None else self.config.enabled + + def run(self): + if self._update_enabled_state(): + self.start_all_ffmpeg() time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): + enabled = self._update_enabled_state() + if enabled != self.was_enabled: + if enabled: + self.logger.debug(f"Enabling camera {self.camera_name}") + self.start_all_ffmpeg() + else: + self.logger.debug(f"Disabling camera {self.camera_name}") + self.stop_all_ffmpeg() + self.was_enabled = enabled + continue + + if not enabled: + continue + now = datetime.datetime.now().timestamp() if not self.capture_thread.is_alive(): @@ -279,11 +302,9 @@ class CameraWatchdog(threading.Thread): p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] ) - stop_ffmpeg(self.ffmpeg_detect_process, self.logger) - for p in self.ffmpeg_other_processes: - stop_ffmpeg(p["process"], self.logger) - p["logpipe"].close() + self.stop_all_ffmpeg() self.logpipe.close() + self.config_subscriber.stop() def start_ffmpeg_detect(self): ffmpeg_cmd = [ @@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread): ) self.capture_thread.start() + def start_all_ffmpeg(self): + """Start all ffmpeg processes (detection and others).""" + logger.debug(f"Starting all ffmpeg processes for {self.camera_name}") + self.start_ffmpeg_detect() + for c in self.config.ffmpeg_cmds: + if "detect" in c["roles"]: + continue + logpipe = LogPipe( + f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" + ) + 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).""" + logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}") + 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( + f"Capture thread for {self.camera_name} did not stop gracefully." + ) + 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() + def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int: """Checks if ffmpeg is still writing recording segments to cache.""" cache_files = sorted( @@ -539,7 +597,8 @@ def process_frames( exit_on_empty: bool = False, ): next_region_update = get_tomorrow_at_time(2) - config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) fps_tracker = EventsPerSecond() fps_tracker.start() @@ -549,9 +608,43 @@ def process_frames( region_min_size = get_min_region_size(model_config) + prev_enabled = None + while not stop_event.is_set(): + _, enabled_config = enabled_config_subscriber.check_for_update() + current_enabled = ( + enabled_config.enabled + if enabled_config + else (prev_enabled if prev_enabled is not None else True) + ) + if prev_enabled is None: + prev_enabled = current_enabled + + if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty(): + logger.debug(f"Camera {camera_name} disabled, clearing tracked objects") + + # Clear norfair's dictionaries + object_tracker.tracked_objects.clear() + object_tracker.disappeared.clear() + object_tracker.stationary_box_history.clear() + object_tracker.positions.clear() + object_tracker.track_id_map.clear() + + # Clear internal norfair states + for trackers_by_type in object_tracker.trackers.values(): + for tracker in trackers_by_type.values(): + tracker.tracked_objects = [] + for tracker in object_tracker.default_tracker.values(): + tracker.tracked_objects = [] + + prev_enabled = current_enabled + + if not current_enabled: + time.sleep(0.1) + continue + # check for updated detect config - _, updated_detect_config = config_subscriber.check_for_update() + _, updated_detect_config = detect_config_subscriber.check_for_update() if updated_detect_config: detect_config = updated_detect_config @@ -845,4 +938,5 @@ def process_frames( motion_detector.stop() requestor.stop() - config_subscriber.stop() + detect_config_subscriber.stop() + enabled_config_subscriber.stop() diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 7ca9ae69d..27600993a 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -56,6 +56,7 @@ function useValue(): useValueReturn { const { record, detect, + enabled, snapshots, audio, notifications, @@ -67,6 +68,7 @@ function useValue(): useValueReturn { // @ts-expect-error we know this is correct state["config"]; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; + cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; @@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) { return { value, send }; } +export function useEnabledState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); + return { payload: payload as ToggleableSetting, send }; +} + export function useDetectState(camera: string): { payload: ToggleableSetting; send: (payload: ToggleableSetting, retain?: boolean) => void; diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index ba35d643e..fe6586fcc 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isDesktop } from "react-device-detect"; import { cn } from "@/lib/utils"; +import { useEnabledState } from "@/api/ws"; type CameraImageProps = { className?: string; @@ -26,7 +27,8 @@ export default function CameraImage({ const imgRef = useRef(null); const { name } = config ? config.cameras[camera] : ""; - const enabled = config ? config.cameras[camera].enabled : "True"; + const { payload: enabledState } = useEnabledState(camera); + const enabled = enabledState === "ON" || enabledState === undefined; const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(containerRef); @@ -96,9 +98,7 @@ export default function CameraImage({ loading="lazy" /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
)} {!imageLoaded && enabled ? (
diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx index 81545c625..fbb57677b 100644 --- a/web/src/components/camera/ResizingCameraImage.tsx +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -108,9 +108,7 @@ export default function CameraImage({ width={scaledWidth} /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
Camera is disabled.
)} {!hasLoaded && enabled ? (
void; + disabled?: boolean; // New prop for disabling }; export default function CameraFeatureToggle({ @@ -35,18 +40,28 @@ export default function CameraFeatureToggle({ Icon, title, onClick, + disabled = false, // Default to false }: CameraFeatureToggleProps) { const content = (
); @@ -54,7 +69,7 @@ export default function CameraFeatureToggle({ if (isDesktop) { return ( - {content} + {content}

{title}

diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 969e647a0..9c775e0ac 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -39,7 +39,11 @@ import { import { cn } from "@/lib/utils"; import { useNavigate } from "react-router-dom"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import { useNotifications, useNotificationSuspend } from "@/api/ws"; +import { + useEnabledState, + useNotifications, + useNotificationSuspend, +} from "@/api/ws"; type LiveContextMenuProps = { className?: string; @@ -83,6 +87,11 @@ export default function LiveContextMenu({ }: LiveContextMenuProps) { const [showSettings, setShowSettings] = useState(false); + // camera enabled + + const { payload: enabledState, send: sendEnabled } = useEnabledState(camera); + const isEnabled = enabledState === "ON"; + // streaming settings const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = @@ -263,7 +272,7 @@ export default function LiveContextMenu({ onClick={handleVolumeIconClick} />
sendEnabled(isEnabled ? "OFF" : "ON")} + > +
+ {isEnabled ? "Disable" : "Enable"} Camera +
+
+ + + +
Mute All Cameras
- +
Unmute All Cameras
- +
{statsState ? "Hide" : "Show"} Stream Stats
- +
navigate(`/settings?page=debug&camera=${camera}`)} + onClick={ + isEnabled + ? () => navigate(`/settings?page=debug&camera=${camera}`) + : undefined + } >
Debug View
@@ -315,10 +339,10 @@ export default function LiveContextMenu({ {cameraGroup && cameraGroup !== "default" && ( <> - +
setShowSettings(true)} + onClick={isEnabled ? () => setShowSettings(true) : undefined} >
Streaming Settings
@@ -328,10 +352,10 @@ export default function LiveContextMenu({ {preferredLiveMode == "jsmpeg" && isRestreamed && ( <> - +
Reset
@@ -342,7 +366,7 @@ export default function LiveContextMenu({ <> - +
Notifications
@@ -382,10 +406,15 @@ export default function LiveContextMenu({ <> { - sendNotification("ON"); - sendNotificationSuspend(0); - }} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => { + sendNotification("ON"); + sendNotificationSuspend(0); + } + : undefined + } >
{notificationState === "ON" ? ( @@ -405,36 +434,71 @@ export default function LiveContextMenu({ Suspend for:

- handleSuspend("5")}> + handleSuspend("5") : undefined + } + > 5 minutes handleSuspend("10")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("10") + : undefined + } > 10 minutes handleSuspend("30")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("30") + : undefined + } > 30 minutes handleSuspend("60")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("60") + : undefined + } > 1 hour handleSuspend("840")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("840") + : undefined + } > 12 hours handleSuspend("1440")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("1440") + : undefined + } > 24 hours handleSuspend("off")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("off") + : undefined + } > Until restart diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 4bd751469..f2b0639a4 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { baseUrl } from "@/api/baseUrl"; import { PlayerStats } from "./PlayerStats"; +import { LuVideoOff } from "react-icons/lu"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -86,8 +87,13 @@ export default function LivePlayer({ // camera activity - const { activeMotion, activeTracking, objects, offline } = - useCameraActivity(cameraConfig); + const { + enabled: cameraEnabled, + activeMotion, + activeTracking, + objects, + offline, + } = useCameraActivity(cameraConfig); const cameraActive = useMemo( () => @@ -191,12 +197,37 @@ export default function LivePlayer({ setLiveReady(true); }, []); + // enabled states + + const [isReEnabling, setIsReEnabling] = useState(false); + const prevCameraEnabledRef = useRef(cameraEnabled); + + useEffect(() => { + if (!prevCameraEnabledRef.current && cameraEnabled) { + // Camera enabled + setLiveReady(false); + setIsReEnabling(true); + setKey((prevKey) => prevKey + 1); + } else if (prevCameraEnabledRef.current && !cameraEnabled) { + // Camera disabled + setLiveReady(false); + setKey((prevKey) => prevKey + 1); + } + prevCameraEnabledRef.current = cameraEnabled; + }, [cameraEnabled]); + + useEffect(() => { + if (liveReady && isReEnabling) { + setIsReEnabling(false); + } + }, [liveReady, isReEnabling]); + if (!cameraConfig) { return ; } let player; - if (!autoLive || !streamName) { + if (!autoLive || !streamName || !cameraEnabled) { player = null; } else if (preferredLiveMode == "webrtc") { player = ( @@ -267,6 +298,22 @@ export default function LivePlayer({ player = ; } + // if (cameraConfig.name == "lpr") + // console.log( + // cameraConfig.name, + // "enabled", + // cameraEnabled, + // "prev enabled", + // prevCameraEnabledRef.current, + // "offline", + // offline, + // "show still", + // showStillWithoutActivity, + // "live ready", + // liveReady, + // player, + // ); + return (
- {((showStillWithoutActivity && !liveReady) || liveReady) && ( - <> -
-
- - )} + {cameraEnabled && + ((showStillWithoutActivity && !liveReady) || liveReady) && ( + <> +
+
+ + )} {player} - {!offline && !showStillWithoutActivity && !liveReady && ( - - )} + {cameraEnabled && + !offline && + (!showStillWithoutActivity || isReEnabling) && + !liveReady && } {((showStillWithoutActivity && !liveReady) || liveReady) && objects.length > 0 && ( @@ -344,7 +393,9 @@ export default function LivePlayer({
)} + {!cameraEnabled && ( +
+
+ +

+ Camera is disabled +

+
+
+ )} +
{autoLive && !offline && @@ -378,7 +440,7 @@ export default function LivePlayer({ ((showStillWithoutActivity && !liveReady) || liveReady) && ( )} - {offline && showStillWithoutActivity && ( + {((offline && showStillWithoutActivity) || !cameraEnabled) && ( diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 247ae8991..c6c5ee474 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -68,7 +68,7 @@ export default function ZoneEditPane({ } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index bbf70ba32..14a575224 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -1,4 +1,5 @@ import { + useEnabledState, useFrigateEvents, useInitialCameraState, useMotionActivity, @@ -15,6 +16,7 @@ import useSWR from "swr"; import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { + enabled: boolean; activeTracking: boolean; activeMotion: boolean; objects: ObjectType[]; @@ -56,6 +58,7 @@ export function useCameraActivity( [objects], ); + const { payload: cameraEnabled } = useEnabledState(camera.name); const { payload: detectingMotion } = useMotionActivity(camera.name); const { payload: event } = useFrigateEvents(); const updatedEvent = useDeepMemo(event); @@ -145,12 +148,17 @@ export function useCameraActivity( return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; }, [camera, stats]); + const isCameraEnabled = cameraEnabled === "ON"; + return { - activeTracking: hasActiveObjects, - activeMotion: detectingMotion - ? detectingMotion === "ON" - : updatedCameraState?.motion === true, - objects, + enabled: isCameraEnabled, + activeTracking: isCameraEnabled ? hasActiveObjects : false, + activeMotion: isCameraEnabled + ? detectingMotion + ? detectingMotion === "ON" + : updatedCameraState?.motion === true + : false, + objects: isCameraEnabled ? objects : [], offline, }; } diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 97e565ef1..016f3cba1 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -101,12 +101,14 @@ function Live() { ) { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) - .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) + .filter( + (conf) => conf.enabled_in_config && group.cameras.includes(conf.name), + ) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config, cameraGroup]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6eeb5bcc3..33f854ba3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; +import { useInitialCameraState } from "@/api/ws"; const allSettingsViews = [ "UI settings", @@ -71,12 +72,33 @@ export default function Settings() { } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); + const { payload: allCameraStates } = useInitialCameraState( + cameras.length > 0 ? cameras[0].name : "", + true, + ); + + const cameraEnabledStates = useMemo(() => { + const states: Record = {}; + if (allCameraStates) { + Object.entries(allCameraStates).forEach(([camName, state]) => { + states[camName] = state.config?.enabled ?? false; + }); + } + // fallback to config if ws data isn’t available yet + cameras.forEach((cam) => { + if (!(cam.name in states)) { + states[cam.name] = cam.enabled; + } + }); + return states; + }, [allCameraStates, cameras]); + const [filterZoneMask, setFilterZoneMask] = useState(); const handleDialog = useCallback( @@ -91,10 +113,25 @@ export default function Settings() { ); useEffect(() => { - if (cameras.length > 0 && selectedCamera === "") { - setSelectedCamera(cameras[0].name); + if (cameras.length > 0) { + if (!selectedCamera) { + // Set to first enabled camera initially if no selection + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + setSelectedCamera(firstEnabledCamera.name); + } else if ( + !cameraEnabledStates[selectedCamera] && + page !== "camera settings" + ) { + // Switch to first enabled camera if current one is disabled, unless on "camera settings" page + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + if (firstEnabledCamera.name !== selectedCamera) { + setSelectedCamera(firstEnabledCamera.name); + } + } } - }, [cameras, selectedCamera]); + }, [cameras, selectedCamera, cameraEnabledStates, page]); useEffect(() => { if (tabsRef.current) { @@ -177,6 +214,8 @@ export default function Settings() { allCameras={cameras} selectedCamera={selectedCamera} setSelectedCamera={setSelectedCamera} + cameraEnabledStates={cameraEnabledStates} + currentPage={page} />
)} @@ -244,17 +283,21 @@ type CameraSelectButtonProps = { allCameras: CameraConfig[]; selectedCamera: string; setSelectedCamera: React.Dispatch>; + cameraEnabledStates: Record; + currentPage: SettingsType; }; function CameraSelectButton({ allCameras, selectedCamera, setSelectedCamera, + cameraEnabledStates, + currentPage, }: CameraSelectButtonProps) { const [open, setOpen] = useState(false); if (!allCameras.length) { - return; + return null; } const trigger = ( @@ -283,19 +326,24 @@ function CameraSelectButton({ )}
- {allCameras.map((item) => ( - { - if (isChecked) { - setSelectedCamera(item.name); - setOpen(false); - } - }} - /> - ))} + {allCameras.map((item) => { + const isEnabled = cameraEnabledStates[item.name]; + const isCameraSettingsPage = currentPage === "camera settings"; + return ( + { + if (isChecked && (isEnabled || isCameraSettingsPage)) { + setSelectedCamera(item.name); + setOpen(false); + } + }} + disabled={!isEnabled && !isCameraSettingsPage} + /> + ); + })}
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 4ec4de853..e468c534f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -57,6 +57,7 @@ export interface CameraConfig { width: number; }; enabled: boolean; + enabled_in_config: boolean; ffmpeg: { global_args: string[]; hwaccel_args: string; diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts index 397b213f6..2590d45a7 100644 --- a/web/src/types/ws.ts +++ b/web/src/types/ws.ts @@ -52,6 +52,7 @@ export type ObjectType = { }; export interface FrigateCameraState { + enabled: boolean; motion: boolean; objects: ObjectType[]; } diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index ccf06de7b..9b45c5a60 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -2,6 +2,7 @@ import { useAudioState, useAutotrackingState, useDetectState, + useEnabledState, usePtzCommand, useRecordingsState, useSnapshotsState, @@ -82,6 +83,8 @@ import { LuHistory, LuInfo, LuPictureInPicture, + LuPower, + LuPowerOff, LuVideo, LuVideoOff, LuX, @@ -185,6 +188,10 @@ export default function LiveCameraView({ ); }, [cameraMetadata]); + // camera enabled state + const { payload: enabledState } = useEnabledState(camera.name); + const cameraEnabled = enabledState === "ON"; + // click overlay for ptzs const [clickOverlay, setClickOverlay] = useState(false); @@ -470,6 +477,7 @@ export default function LiveCameraView({ setPip(false); } }} + disabled={!cameraEnabled} /> )} {supports2WayTalk && ( @@ -481,11 +489,11 @@ export default function LiveCameraView({ title={`${mic ? "Disable" : "Enable"} Two Way Talk`} onClick={() => { setMic(!mic); - // Turn on audio when enabling the mic if audio is currently off if (!mic && !audio) { setAudio(true); } }} + disabled={!cameraEnabled} /> )} {supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( @@ -496,6 +504,7 @@ export default function LiveCameraView({ isActive={audio ?? false} title={`${audio ? "Disable" : "Enable"} Camera Audio`} onClick={() => setAudio(!audio)} + disabled={!cameraEnabled} /> )}
@@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = { setLowBandwidth: React.Dispatch>; supportsAudioOutput: boolean; supports2WayTalk: boolean; + cameraEnabled: boolean; }; function FrigateCameraFeatures({ camera, @@ -931,10 +942,14 @@ function FrigateCameraFeatures({ setLowBandwidth, supportsAudioOutput, supports2WayTalk, + cameraEnabled, }: FrigateCameraFeaturesProps) { const { payload: detectState, send: sendDetect } = useDetectState( camera.name, ); + const { payload: enabledState, send: sendEnabled } = useEnabledState( + camera.name, + ); const { payload: recordState, send: sendRecord } = useRecordingsState( camera.name, ); @@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> )} {autotrackingEnabled && ( @@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({ onClick={() => sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } + disabled={!cameraEnabled} /> )} diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index fa9d0ba58..e2c1ca563 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { useAlertsState, useDetectionsState } from "@/api/ws"; +import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws"; type CameraSettingsViewProps = { selectedCamera: string; @@ -108,6 +108,8 @@ export default function CameraSettingsView({ const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); + const { payload: enabledState, send: sendEnabled } = + useEnabledState(selectedCamera); const { payload: alertsState, send: sendAlerts } = useAlertsState(selectedCamera); const { payload: detectionsState, send: sendDetections } = @@ -252,6 +254,31 @@ export default function CameraSettingsView({ + + Streams + + +
+ { + sendEnabled(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ Disabling a camera completely stops Frigate's processing of this + camera's streams. Detection, recording, and debugging will be + unavailable. +
Note: This does not disable go2rtc restreams. +
+ + Review diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index edae6ba28..fcda4adb1 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -80,7 +80,7 @@ export default function NotificationView({ return Object.values(config.cameras) .filter( (conf) => - conf.enabled && + conf.enabled_in_config && conf.notifications && conf.notifications.enabled_in_config, )