Dynamically enable/disable cameras (#16894)

* config options

* metrics

* stop and restart ffmpeg processes

* dispatcher

* frontend websocket

* buttons for testing

* don't recreate log pipe

* add/remove cam from birdseye when enabling/disabling

* end all objects and send empty camera activity

* enable/disable switch in ui

* disable buttons when camera is disabled

* use enabled_in_config for some frontend checks

* tweaks

* handle settings pane with disabled cameras

* frontend tweaks

* change to debug log

* mqtt docs

* tweak

* ensure all ffmpeg processes are initially started

* clean up

* use zmq

* remove camera metrics

* remove camera metrics

* tweaks

* frontend tweaks
This commit is contained in:
Josh Hawkins 2025-03-03 09:30:52 -06:00 committed by GitHub
parent 71e6e04d77
commit 531042467a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 713 additions and 202 deletions

View File

@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera.
**NOTE:** Requires audio detection to be enabled **NOTE:** Requires audio detection to be enabled
### `frigate/<camera_name>/enabled/set`
Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/enabled/state`
Topic with current state of processing for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/detect/set` ### `frigate/<camera_name>/detect/set`
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.

View File

@ -20,7 +20,7 @@ class CameraActivityManager:
self.all_zone_labels: dict[str, set[str]] = {} self.all_zone_labels: dict[str, set[str]] = {}
for camera_config in config.cameras.values(): for camera_config in config.cameras.values():
if not camera_config.enabled: if not camera_config.enabled_in_config:
continue continue
self.last_camera_activity[camera_config.name] = {} self.last_camera_activity[camera_config.name] = {}

View File

@ -55,6 +55,7 @@ class Dispatcher:
self._camera_settings_handlers: dict[str, Callable] = { self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command, "audio": self._on_audio_command,
"detect": self._on_detect_command, "detect": self._on_detect_command,
"enabled": self._on_enabled_command,
"improve_contrast": self._on_motion_improve_contrast_command, "improve_contrast": self._on_motion_improve_contrast_command,
"ptz_autotracker": self._on_ptz_autotracker_command, "ptz_autotracker": self._on_ptz_autotracker_command,
"motion": self._on_motion_command, "motion": self._on_motion_command,
@ -167,6 +168,7 @@ class Dispatcher:
for camera in camera_status.keys(): for camera in camera_status.keys():
camera_status[camera]["config"] = { camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled, "detect": self.config.cameras[camera].detect.enabled,
"enabled": self.config.cameras[camera].enabled,
"snapshots": self.config.cameras[camera].snapshots.enabled, "snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled, "record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.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.config_updater.publish(f"config/detect/{camera_name}", detect_settings)
self.publish(f"{camera_name}/detect/state", payload, retain=True) 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: def _on_motion_command(self, camera_name: str, payload: str) -> None:
"""Callback for motion topic.""" """Callback for motion topic."""
detect_settings = self.config.cameras[camera_name].detect detect_settings = self.config.cameras[camera_name].detect

View File

@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel):
zones: dict[str, ZoneConfig] = Field( zones: dict[str, ZoneConfig] = Field(
default_factory=dict, title="Zone configuration." 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() _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()

View File

@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel):
camera_config.detect.stationary.interval = stationary_threshold camera_config.detect.stationary.interval = stationary_threshold
# set config pre-value # set config pre-value
camera_config.enabled_in_config = camera_config.enabled
camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled
camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.record.enabled_in_config = camera_config.record.enabled
camera_config.notifications.enabled_in_config = ( camera_config.notifications.enabled_in_config = (

View File

@ -10,6 +10,7 @@ from typing import Callable, Optional
import cv2 import cv2
import numpy as np import numpy as np
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.dispatcher import Dispatcher from frigate.comms.dispatcher import Dispatcher
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
@ -61,6 +62,7 @@ class CameraState:
self.previous_frame_id = None self.previous_frame_id = None
self.callbacks = defaultdict(list) self.callbacks = defaultdict(list)
self.ptz_autotracker_thread = ptz_autotracker_thread self.ptz_autotracker_thread = ptz_autotracker_thread
self.prev_enabled = self.camera_config.enabled
def get_current_frame(self, draw_options={}): def get_current_frame(self, draw_options={}):
with self.current_frame_lock: 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? # TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects # maintain best objects
camera_activity: dict[str, list[any]] = { camera_activity: dict[str, list[any]] = {
"enabled": True,
"motion": len(motion_boxes) > 0, "motion": len(motion_boxes) > 0,
"objects": [], "objects": [],
} }
@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread):
self.last_motion_detected: dict[str, float] = {} self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread 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.requestor = InterProcessRequestor()
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
self.event_sender = EventUpdatePublisher() self.event_sender = EventUpdatePublisher()
@ -679,8 +687,55 @@ class TrackedObjectProcessor(threading.Thread):
"""Returns the latest frame time for a given camera.""" """Returns the latest frame time for a given camera."""
return self.camera_states[camera].current_frame_time 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): def run(self):
while not self.stop_event.is_set(): 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: try:
( (
camera, camera,
@ -693,6 +748,10 @@ class TrackedObjectProcessor(threading.Thread):
except queue.Empty: except queue.Empty:
continue 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 = self.camera_states[camera]
camera_state.update( camera_state.update(
@ -735,4 +794,7 @@ class TrackedObjectProcessor(threading.Thread):
self.detection_publisher.stop() self.detection_publisher.stop()
self.event_sender.stop() self.event_sender.stop()
self.event_end_subscriber.stop() self.event_end_subscriber.stop()
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
logger.info("Exiting object processor...") logger.info("Exiting object processor...")

View File

@ -10,6 +10,7 @@ import queue
import subprocess as sp import subprocess as sp
import threading import threading
import traceback import traceback
from typing import Optional
import cv2 import cv2
import numpy as np import numpy as np
@ -280,6 +281,12 @@ class BirdsEyeFrameManager:
self.stop_event = stop_event self.stop_event = stop_event
self.inactivity_threshold = config.birdseye.inactivity_threshold 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: if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0 self.last_refresh_time = 0
@ -380,8 +387,18 @@ class BirdsEyeFrameManager:
if mode == BirdseyeModeEnum.objects and object_box_count > 0: if mode == BirdseyeModeEnum.objects and object_box_count > 0:
return True return True
def update_frame(self, frame: np.ndarray): def _get_enabled_state(self, camera: str) -> bool:
"""Update to a new frame for birdseye.""" """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 # determine how many cameras are tracking objects within the last inactivity_threshold seconds
active_cameras: set[str] = set( active_cameras: set[str] = set(
@ -389,11 +406,14 @@ class BirdsEyeFrameManager:
cam cam
for cam, cam_data in self.cameras.items() for cam, cam_data in self.cameras.items()
if self.config.cameras[cam].birdseye.enabled 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["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"] and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.inactivity_threshold < self.inactivity_threshold
] ]
) )
logger.debug(f"Active cameras: {active_cameras}")
max_cameras = self.config.birdseye.layout.max_cameras max_cameras = self.config.birdseye.layout.max_cameras
max_camera_refresh = False max_camera_refresh = False
@ -411,27 +431,30 @@ class BirdsEyeFrameManager:
- self.cameras[active_camera]["last_active_frame"] - self.cameras[active_camera]["last_active_frame"]
), ),
) )
active_cameras = limited_active_cameras[ active_cameras = limited_active_cameras[:max_cameras]
: self.config.birdseye.layout.max_cameras
]
max_camera_refresh = True max_camera_refresh = True
self.last_refresh_time = now 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 len(active_cameras) == 0:
# if the layout is already cleared # if the layout is already cleared
if len(self.camera_layout) == 0: if len(self.camera_layout) == 0:
return False return False
# if the layout needs to be cleared # if the layout needs to be cleared
else:
self.camera_layout = [] self.camera_layout = []
self.active_cameras = set() self.active_cameras = set()
self.clear_frame() self.clear_frame()
return True frame_changed = True
else:
# check if we need to reset the layout because there is a different number of cameras # Determine if layout needs resetting
if len(self.active_cameras) - len(active_cameras) == 0: if len(self.active_cameras) - len(active_cameras) == 0:
if len(self.active_cameras) == 1 and self.active_cameras != active_cameras: if (
len(self.active_cameras) == 1
and self.active_cameras != active_cameras
):
reset_layout = True reset_layout = True
elif max_camera_refresh: elif max_camera_refresh:
reset_layout = True reset_layout = True
@ -440,9 +463,8 @@ class BirdsEyeFrameManager:
else: else:
reset_layout = True reset_layout = True
# reset the layout if it needs to be different
if reset_layout: if reset_layout:
logger.debug("Added new cameras, resetting layout...") logger.debug("Resetting Birdseye layout...")
self.clear_frame() self.clear_frame()
self.active_cameras = active_cameras self.active_cameras = active_cameras
@ -456,12 +478,13 @@ class BirdsEyeFrameManager:
active_camera, active_camera,
), ),
) )
if len(active_cameras) == 1: if len(active_cameras) == 1:
# show single camera as fullscreen # show single camera as fullscreen
camera = active_cameras_to_add[0] camera = active_cameras_to_add[0]
camera_dims = self.cameras[camera]["dimensions"].copy() camera_dims = self.cameras[camera]["dimensions"].copy()
scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1]) scaled_width = int(
self.canvas.height * camera_dims[0] / camera_dims[1]
)
# center camera view in canvas and ensure that it fits # center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width: if scaled_width < self.canvas.width:
@ -497,8 +520,7 @@ class BirdsEyeFrameManager:
return return
layout_candidate = self.calculate_layout( layout_candidate = self.calculate_layout(
active_cameras_to_add, active_cameras_to_add, coefficient
coefficient,
) )
if not layout_candidate: if not layout_candidate:
@ -506,23 +528,28 @@ class BirdsEyeFrameManager:
coefficient += 1 coefficient += 1
continue continue
else: else:
logger.error("Error finding appropriate birdseye layout") logger.error(
"Error finding appropriate birdseye layout"
)
return return
calculating = False calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient) self.canvas.set_coefficient(len(active_cameras), coefficient)
self.camera_layout = layout_candidate self.camera_layout = layout_candidate
frame_changed = True
# Draw the layout
for row in self.camera_layout: for row in self.camera_layout:
for position in row: for position in row:
self.copy_to_position( src_frame = self.cameras[position[0]]["current_frame"]
position[1], if src_frame is None or src_frame.size == 0:
position[0], logger.debug(f"Skipping invalid frame for {position[0]}")
self.cameras[position[0]]["current_frame"], 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 True return frame_changed
def calculate_layout( def calculate_layout(
self, self,
@ -678,11 +705,8 @@ class BirdsEyeFrameManager:
# don't process if birdseye is disabled for this camera # don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera].birdseye camera_config = self.config.cameras[camera].birdseye
if not camera_config.enabled:
return False
# disabling birdseye is a little tricky # 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) # if we've rendered a frame (we have a value for last_active_frame)
# then we need to set it to zero # then we need to set it to zero
if self.cameras[camera]["last_active_frame"] > 0: if self.cameras[camera]["last_active_frame"] > 0:
@ -716,6 +740,11 @@ class BirdsEyeFrameManager:
return True return True
return False return False
def stop(self):
"""Clean up subscribers when stopping."""
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
class Birdseye: class Birdseye:
def __init__( def __init__(
@ -743,6 +772,7 @@ class Birdseye:
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
self.config_subscriber = ConfigSubscriber("config/birdseye/") self.config_subscriber = ConfigSubscriber("config/birdseye/")
self.frame_manager = SharedMemoryFrameManager() self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
if config.birdseye.restream: if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create( self.birdseye_buffer = self.frame_manager.create(
@ -794,5 +824,6 @@ class Birdseye:
def stop(self) -> None: def stop(self) -> None:
self.config_subscriber.stop() self.config_subscriber.stop()
self.birdseye_manager.stop()
self.converter.join() self.converter.join()
self.broadcaster.join() self.broadcaster.join()

View File

@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import (
) )
from ws4py.server.wsgiutils import WebSocketWSGIApplication from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import WebSocket from frigate.comms.ws import WebSocket
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@ -59,6 +60,12 @@ def output_frames(
detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) 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] = {} jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Optional[Birdseye] = None birdseye: Optional[Birdseye] = None
preview_recorders: dict[str, PreviewRecorder] = {} preview_recorders: dict[str, PreviewRecorder] = {}
@ -80,6 +87,13 @@ def output_frames(
websocket_thread.start() 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(): while not stop_event.is_set():
(topic, data) = detection_subscriber.check_for_update(timeout=1) (topic, data) = detection_subscriber.check_for_update(timeout=1)
@ -95,6 +109,9 @@ def output_frames(
_, _,
) = data ) = data
if not get_enabled_state(camera):
continue
frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv)
if frame is None: if frame is None:
@ -184,6 +201,9 @@ def output_frames(
if birdseye is not None: if birdseye is not None:
birdseye.stop() birdseye.stop()
for subscriber in enabled_subscribers.values():
subscriber.stop()
websocket_server.manager.close_all() websocket_server.manager.close_all()
websocket_server.manager.stop() websocket_server.manager.stop()
websocket_server.manager.join() websocket_server.manager.join()

View File

@ -108,8 +108,20 @@ def capture_frames(
frame_rate.start() frame_rate.start()
skipped_eps = EventsPerSecond() skipped_eps = EventsPerSecond()
skipped_eps.start() 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() fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps() skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.datetime.now().timestamp() current_frame.value = datetime.datetime.now().timestamp()
@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval self.sleeptime = self.config.ffmpeg.retry_interval
def run(self): self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True)
self.start_ffmpeg_detect() self.was_enabled = self.config.enabled
for c in self.config.ffmpeg_cmds: def _update_enabled_state(self) -> bool:
if "detect" in c["roles"]: """Fetch the latest config and update enabled state."""
continue _, config_data = self.config_subscriber.check_for_update()
logpipe = LogPipe( if config_data:
f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" enabled = config_data.enabled
) return enabled
self.ffmpeg_other_processes.append( return self.was_enabled if self.was_enabled is not None else self.config.enabled
{
"cmd": c["cmd"], def run(self):
"roles": c["roles"], if self._update_enabled_state():
"logpipe": logpipe, self.start_all_ffmpeg()
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
}
)
time.sleep(self.sleeptime) time.sleep(self.sleeptime)
while not self.stop_event.wait(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() now = datetime.datetime.now().timestamp()
if not self.capture_thread.is_alive(): 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"] p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
) )
stop_ffmpeg(self.ffmpeg_detect_process, self.logger) self.stop_all_ffmpeg()
for p in self.ffmpeg_other_processes:
stop_ffmpeg(p["process"], self.logger)
p["logpipe"].close()
self.logpipe.close() self.logpipe.close()
self.config_subscriber.stop()
def start_ffmpeg_detect(self): def start_ffmpeg_detect(self):
ffmpeg_cmd = [ ffmpeg_cmd = [
@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread):
) )
self.capture_thread.start() 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: def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int:
"""Checks if ffmpeg is still writing recording segments to cache.""" """Checks if ffmpeg is still writing recording segments to cache."""
cache_files = sorted( cache_files = sorted(
@ -539,7 +597,8 @@ def process_frames(
exit_on_empty: bool = False, exit_on_empty: bool = False,
): ):
next_region_update = get_tomorrow_at_time(2) 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 = EventsPerSecond()
fps_tracker.start() fps_tracker.start()
@ -549,9 +608,43 @@ def process_frames(
region_min_size = get_min_region_size(model_config) region_min_size = get_min_region_size(model_config)
prev_enabled = None
while not stop_event.is_set(): 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 # 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: if updated_detect_config:
detect_config = updated_detect_config detect_config = updated_detect_config
@ -845,4 +938,5 @@ def process_frames(
motion_detector.stop() motion_detector.stop()
requestor.stop() requestor.stop()
config_subscriber.stop() detect_config_subscriber.stop()
enabled_config_subscriber.stop()

View File

@ -56,6 +56,7 @@ function useValue(): useValueReturn {
const { const {
record, record,
detect, detect,
enabled,
snapshots, snapshots,
audio, audio,
notifications, notifications,
@ -67,6 +68,7 @@ function useValue(): useValueReturn {
// @ts-expect-error we know this is correct // @ts-expect-error we know this is correct
state["config"]; state["config"];
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) {
return { value, send }; 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): { export function useDetectState(camera: string): {
payload: ToggleableSetting; payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void; send: (payload: ToggleableSetting, retain?: boolean) => void;

View File

@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEnabledState } from "@/api/ws";
type CameraImageProps = { type CameraImageProps = {
className?: string; className?: string;
@ -26,7 +27,8 @@ export default function CameraImage({
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : ""; 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 }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef); useResizeObserver(containerRef);
@ -96,9 +98,7 @@ export default function CameraImage({
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<div className="pt-6 text-center"> <div className="size-full rounded-lg border-2 border-muted bg-background_alt text-center md:rounded-2xl" />
Camera is disabled in config, no stream or snapshot available!
</div>
)} )}
{!imageLoaded && enabled ? ( {!imageLoaded && enabled ? (
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center"> <div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">

View File

@ -108,9 +108,7 @@ export default function CameraImage({
width={scaledWidth} width={scaledWidth}
/> />
) : ( ) : (
<div className="pt-6 text-center"> <div className="pt-6 text-center">Camera is disabled.</div>
Camera is disabled in config, no stream or snapshot available!
</div>
)} )}
{!hasLoaded && enabled ? ( {!hasLoaded && enabled ? (
<div <div

View File

@ -11,11 +11,15 @@ const variants = {
primary: { primary: {
active: "font-bold text-white bg-selected rounded-lg", active: "font-bold text-white bg-selected rounded-lg",
inactive: "text-secondary-foreground bg-secondary rounded-lg", inactive: "text-secondary-foreground bg-secondary rounded-lg",
disabled:
"text-secondary-foreground bg-secondary rounded-lg cursor-not-allowed opacity-50",
}, },
overlay: { overlay: {
active: "font-bold text-white bg-selected rounded-full", active: "font-bold text-white bg-selected rounded-full",
inactive: inactive:
"text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500", "text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
disabled:
"bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-full cursor-not-allowed opacity-50",
}, },
}; };
@ -26,6 +30,7 @@ type CameraFeatureToggleProps = {
Icon: IconType; Icon: IconType;
title: string; title: string;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; // New prop for disabling
}; };
export default function CameraFeatureToggle({ export default function CameraFeatureToggle({
@ -35,18 +40,28 @@ export default function CameraFeatureToggle({
Icon, Icon,
title, title,
onClick, onClick,
disabled = false, // Default to false
}: CameraFeatureToggleProps) { }: CameraFeatureToggleProps) {
const content = ( const content = (
<div <div
onClick={onClick} onClick={disabled ? undefined : onClick}
className={cn( className={cn(
"flex flex-col items-center justify-center", "flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"], disabled
? variants[variant].disabled
: variants[variant][isActive ? "active" : "inactive"],
className, className,
)} )}
> >
<Icon <Icon
className={`size-5 md:m-[6px] ${isActive ? "text-white" : "text-secondary-foreground"}`} className={cn(
"size-5 md:m-[6px]",
disabled
? "text-gray-400"
: isActive
? "text-white"
: "text-secondary-foreground",
)}
/> />
</div> </div>
); );
@ -54,7 +69,7 @@ export default function CameraFeatureToggle({
if (isDesktop) { if (isDesktop) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger>{content}</TooltipTrigger> <TooltipTrigger disabled={disabled}>{content}</TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<p>{title}</p> <p>{title}</p>
</TooltipContent> </TooltipContent>

View File

@ -39,7 +39,11 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useNotifications, useNotificationSuspend } from "@/api/ws"; import {
useEnabledState,
useNotifications,
useNotificationSuspend,
} from "@/api/ws";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;
@ -83,6 +87,11 @@ export default function LiveContextMenu({
}: LiveContextMenuProps) { }: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
// camera enabled
const { payload: enabledState, send: sendEnabled } = useEnabledState(camera);
const isEnabled = enabledState === "ON";
// streaming settings // streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
@ -263,7 +272,7 @@ export default function LiveContextMenu({
onClick={handleVolumeIconClick} onClick={handleVolumeIconClick}
/> />
<VolumeSlider <VolumeSlider
disabled={!audioState} disabled={!audioState || !isEnabled}
className="my-3 ml-0.5 rounded-lg bg-background/60" className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]} value={[volumeState ?? 0]}
min={0} min={0}
@ -280,34 +289,49 @@ export default function LiveContextMenu({
<ContextMenuItem> <ContextMenuItem>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll} onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? "Disable" : "Enable"} Camera
</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? muteAll : undefined}
> >
<div className="text-primary">Mute All Cameras</div> <div className="text-primary">Mute All Cameras</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem> <ContextMenuItem disabled={!isEnabled}>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll} onClick={isEnabled ? unmuteAll : undefined}
> >
<div className="text-primary">Unmute All Cameras</div> <div className="text-primary">Unmute All Cameras</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem> <ContextMenuItem disabled={!isEnabled}>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats} onClick={isEnabled ? toggleStats : undefined}
> >
<div className="text-primary"> <div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats {statsState ? "Hide" : "Show"} Stream Stats
</div> </div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem> <ContextMenuItem disabled={!isEnabled}>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)} onClick={
isEnabled
? () => navigate(`/settings?page=debug&camera=${camera}`)
: undefined
}
> >
<div className="text-primary">Debug View</div> <div className="text-primary">Debug View</div>
</div> </div>
@ -315,10 +339,10 @@ export default function LiveContextMenu({
{cameraGroup && cameraGroup !== "default" && ( {cameraGroup && cameraGroup !== "default" && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem> <ContextMenuItem disabled={!isEnabled}>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)} onClick={isEnabled ? () => setShowSettings(true) : undefined}
> >
<div className="text-primary">Streaming Settings</div> <div className="text-primary">Streaming Settings</div>
</div> </div>
@ -328,10 +352,10 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && ( {preferredLiveMode == "jsmpeg" && isRestreamed && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem> <ContextMenuItem disabled={!isEnabled}>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2" className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode} onClick={isEnabled ? resetPreferredLiveMode : undefined}
> >
<div className="text-primary">Reset</div> <div className="text-primary">Reset</div>
</div> </div>
@ -342,7 +366,7 @@ export default function LiveContextMenu({
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger> <ContextMenuSubTrigger disabled={!isEnabled}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Notifications</span> <span>Notifications</span>
</div> </div>
@ -382,10 +406,15 @@ export default function LiveContextMenu({
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
onClick={() => { disabled={!isEnabled}
onClick={
isEnabled
? () => {
sendNotification("ON"); sendNotification("ON");
sendNotificationSuspend(0); sendNotificationSuspend(0);
}} }
: undefined
}
> >
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
{notificationState === "ON" ? ( {notificationState === "ON" ? (
@ -405,36 +434,71 @@ export default function LiveContextMenu({
Suspend for: Suspend for:
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
<ContextMenuItem onClick={() => handleSuspend("5")}> <ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled ? () => handleSuspend("5") : undefined
}
>
5 minutes 5 minutes
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("10")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("10")
: undefined
}
> >
10 minutes 10 minutes
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("30")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("30")
: undefined
}
> >
30 minutes 30 minutes
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("60")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("60")
: undefined
}
> >
1 hour 1 hour
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("840")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("840")
: undefined
}
> >
12 hours 12 hours
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("1440")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("1440")
: undefined
}
> >
24 hours 24 hours
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem
onClick={() => handleSuspend("off")} disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("off")
: undefined
}
> >
Until restart Until restart
</ContextMenuItem> </ContextMenuItem>

View File

@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats"; import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
@ -86,8 +87,13 @@ export default function LivePlayer({
// camera activity // camera activity
const { activeMotion, activeTracking, objects, offline } = const {
useCameraActivity(cameraConfig); enabled: cameraEnabled,
activeMotion,
activeTracking,
objects,
offline,
} = useCameraActivity(cameraConfig);
const cameraActive = useMemo( const cameraActive = useMemo(
() => () =>
@ -191,12 +197,37 @@ export default function LivePlayer({
setLiveReady(true); 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) { if (!cameraConfig) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
let player; let player;
if (!autoLive || !streamName) { if (!autoLive || !streamName || !cameraEnabled) {
player = null; player = null;
} else if (preferredLiveMode == "webrtc") { } else if (preferredLiveMode == "webrtc") {
player = ( player = (
@ -267,6 +298,22 @@ export default function LivePlayer({
player = <ActivityIndicator />; player = <ActivityIndicator />;
} }
// if (cameraConfig.name == "lpr")
// console.log(
// cameraConfig.name,
// "enabled",
// cameraEnabled,
// "prev enabled",
// prevCameraEnabledRef.current,
// "offline",
// offline,
// "show still",
// showStillWithoutActivity,
// "live ready",
// liveReady,
// player,
// );
return ( return (
<div <div
ref={cameraRef ?? internalContainerRef} ref={cameraRef ?? internalContainerRef}
@ -287,16 +334,18 @@ export default function LivePlayer({
} }
}} }}
> >
{((showStillWithoutActivity && !liveReady) || liveReady) && ( {cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && (
<> <>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div> <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div> <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</> </>
)} )}
{player} {player}
{!offline && !showStillWithoutActivity && !liveReady && ( {cameraEnabled &&
<ActivityIndicator /> !offline &&
)} (!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
{((showStillWithoutActivity && !liveReady) || liveReady) && {((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && ( objects.length > 0 && (
@ -344,7 +393,9 @@ export default function LivePlayer({
<div <div
className={cn( className={cn(
"absolute inset-0 w-full", "absolute inset-0 w-full",
showStillWithoutActivity && !liveReady ? "visible" : "invisible", showStillWithoutActivity && !liveReady && !isReEnabling
? "visible"
: "invisible",
)} )}
> >
<AutoUpdatingCameraImage <AutoUpdatingCameraImage
@ -371,6 +422,17 @@ export default function LivePlayer({
</div> </div>
)} )}
{!cameraEnabled && (
<div className="relative flex h-full w-full items-center justify-center">
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
Camera is disabled
</p>
</div>
</div>
)}
<div className="absolute right-2 top-2"> <div className="absolute right-2 top-2">
{autoLive && {autoLive &&
!offline && !offline &&
@ -378,7 +440,7 @@ export default function LivePlayer({
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" /> <MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)} )}
{offline && showStillWithoutActivity && ( {((offline && showStillWithoutActivity) || !cameraEnabled) && (
<Chip <Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`} className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
> >

View File

@ -68,7 +68,7 @@ export default function ZoneEditPane({
} }
return Object.values(config.cameras) 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); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config]);

View File

@ -1,4 +1,5 @@
import { import {
useEnabledState,
useFrigateEvents, useFrigateEvents,
useInitialCameraState, useInitialCameraState,
useMotionActivity, useMotionActivity,
@ -15,6 +16,7 @@ import useSWR from "swr";
import { getAttributeLabels } from "@/utils/iconUtil"; import { getAttributeLabels } from "@/utils/iconUtil";
type useCameraActivityReturn = { type useCameraActivityReturn = {
enabled: boolean;
activeTracking: boolean; activeTracking: boolean;
activeMotion: boolean; activeMotion: boolean;
objects: ObjectType[]; objects: ObjectType[];
@ -56,6 +58,7 @@ export function useCameraActivity(
[objects], [objects],
); );
const { payload: cameraEnabled } = useEnabledState(camera.name);
const { payload: detectingMotion } = useMotionActivity(camera.name); const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: event } = useFrigateEvents(); const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event); const updatedEvent = useDeepMemo(event);
@ -145,12 +148,17 @@ export function useCameraActivity(
return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60;
}, [camera, stats]); }, [camera, stats]);
const isCameraEnabled = cameraEnabled === "ON";
return { return {
activeTracking: hasActiveObjects, enabled: isCameraEnabled,
activeMotion: detectingMotion activeTracking: isCameraEnabled ? hasActiveObjects : false,
activeMotion: isCameraEnabled
? detectingMotion
? detectingMotion === "ON" ? detectingMotion === "ON"
: updatedCameraState?.motion === true, : updatedCameraState?.motion === true
objects, : false,
objects: isCameraEnabled ? objects : [],
offline, offline,
}; };
} }

View File

@ -101,12 +101,14 @@ function Live() {
) { ) {
const group = config.camera_groups[cameraGroup]; const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras) 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); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
} }
return Object.values(config.cameras) 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); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config, cameraGroup]); }, [config, cameraGroup]);

View File

@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
const allSettingsViews = [ const allSettingsViews = [
"UI settings", "UI settings",
@ -71,12 +72,33 @@ export default function Settings() {
} }
return Object.values(config.cameras) 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); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>(""); const [selectedCamera, setSelectedCamera] = useState<string>("");
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>(); const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback( const handleDialog = useCallback(
@ -91,10 +113,25 @@ export default function Settings() {
); );
useEffect(() => { useEffect(() => {
if (cameras.length > 0 && selectedCamera === "") { if (cameras.length > 0) {
setSelectedCamera(cameras[0].name); 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(() => { useEffect(() => {
if (tabsRef.current) { if (tabsRef.current) {
@ -177,6 +214,8 @@ export default function Settings() {
allCameras={cameras} allCameras={cameras}
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera} setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/> />
</div> </div>
)} )}
@ -244,17 +283,21 @@ type CameraSelectButtonProps = {
allCameras: CameraConfig[]; allCameras: CameraConfig[];
selectedCamera: string; selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>; setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
}; };
function CameraSelectButton({ function CameraSelectButton({
allCameras, allCameras,
selectedCamera, selectedCamera,
setSelectedCamera, setSelectedCamera,
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) { }: CameraSelectButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!allCameras.length) { if (!allCameras.length) {
return; return null;
} }
const trigger = ( const trigger = (
@ -283,19 +326,24 @@ function CameraSelectButton({
)} )}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1"> <div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{allCameras.map((item) => ( {allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "camera settings";
return (
<FilterSwitch <FilterSwitch
key={item.name} key={item.name}
isChecked={item.name === selectedCamera} isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")} label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name); setSelectedCamera(item.name);
setOpen(false); setOpen(false);
} }
}} }}
disabled={!isEnabled && !isCameraSettingsPage}
/> />
))} );
})}
</div> </div>
</div> </div>
</> </>

View File

@ -57,6 +57,7 @@ export interface CameraConfig {
width: number; width: number;
}; };
enabled: boolean; enabled: boolean;
enabled_in_config: boolean;
ffmpeg: { ffmpeg: {
global_args: string[]; global_args: string[];
hwaccel_args: string; hwaccel_args: string;

View File

@ -52,6 +52,7 @@ export type ObjectType = {
}; };
export interface FrigateCameraState { export interface FrigateCameraState {
enabled: boolean;
motion: boolean; motion: boolean;
objects: ObjectType[]; objects: ObjectType[];
} }

View File

@ -2,6 +2,7 @@ import {
useAudioState, useAudioState,
useAutotrackingState, useAutotrackingState,
useDetectState, useDetectState,
useEnabledState,
usePtzCommand, usePtzCommand,
useRecordingsState, useRecordingsState,
useSnapshotsState, useSnapshotsState,
@ -82,6 +83,8 @@ import {
LuHistory, LuHistory,
LuInfo, LuInfo,
LuPictureInPicture, LuPictureInPicture,
LuPower,
LuPowerOff,
LuVideo, LuVideo,
LuVideoOff, LuVideoOff,
LuX, LuX,
@ -185,6 +188,10 @@ export default function LiveCameraView({
); );
}, [cameraMetadata]); }, [cameraMetadata]);
// camera enabled state
const { payload: enabledState } = useEnabledState(camera.name);
const cameraEnabled = enabledState === "ON";
// click overlay for ptzs // click overlay for ptzs
const [clickOverlay, setClickOverlay] = useState(false); const [clickOverlay, setClickOverlay] = useState(false);
@ -470,6 +477,7 @@ export default function LiveCameraView({
setPip(false); setPip(false);
} }
}} }}
disabled={!cameraEnabled}
/> />
)} )}
{supports2WayTalk && ( {supports2WayTalk && (
@ -481,11 +489,11 @@ export default function LiveCameraView({
title={`${mic ? "Disable" : "Enable"} Two Way Talk`} title={`${mic ? "Disable" : "Enable"} Two Way Talk`}
onClick={() => { onClick={() => {
setMic(!mic); setMic(!mic);
// Turn on audio when enabling the mic if audio is currently off
if (!mic && !audio) { if (!mic && !audio) {
setAudio(true); setAudio(true);
} }
}} }}
disabled={!cameraEnabled}
/> />
)} )}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( {supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@ -496,6 +504,7 @@ export default function LiveCameraView({
isActive={audio ?? false} isActive={audio ?? false}
title={`${audio ? "Disable" : "Enable"} Camera Audio`} title={`${audio ? "Disable" : "Enable"} Camera Audio`}
onClick={() => setAudio(!audio)} onClick={() => setAudio(!audio)}
disabled={!cameraEnabled}
/> />
)} )}
<FrigateCameraFeatures <FrigateCameraFeatures
@ -517,6 +526,7 @@ export default function LiveCameraView({
setLowBandwidth={setLowBandwidth} setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput} supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk} supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
/> />
</div> </div>
</TooltipProvider> </TooltipProvider>
@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = {
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>; setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean; supportsAudioOutput: boolean;
supports2WayTalk: boolean; supports2WayTalk: boolean;
cameraEnabled: boolean;
}; };
function FrigateCameraFeatures({ function FrigateCameraFeatures({
camera, camera,
@ -931,10 +942,14 @@ function FrigateCameraFeatures({
setLowBandwidth, setLowBandwidth,
supportsAudioOutput, supportsAudioOutput,
supports2WayTalk, supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) { }: FrigateCameraFeaturesProps) {
const { payload: detectState, send: sendDetect } = useDetectState( const { payload: detectState, send: sendDetect } = useDetectState(
camera.name, camera.name,
); );
const { payload: enabledState, send: sendEnabled } = useEnabledState(
camera.name,
);
const { payload: recordState, send: sendRecord } = useRecordingsState( const { payload: recordState, send: sendRecord } = useRecordingsState(
camera.name, camera.name,
); );
@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({
if (isDesktop || isTablet) { if (isDesktop || isTablet) {
return ( return (
<> <>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"} variant={fullscreen ? "overlay" : "primary"}
@ -1050,6 +1074,7 @@ function FrigateCameraFeatures({
isActive={detectState == "ON"} isActive={detectState == "ON"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`} title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")} onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/> />
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"
@ -1058,6 +1083,7 @@ function FrigateCameraFeatures({
isActive={recordState == "ON"} isActive={recordState == "ON"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`} title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/> />
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"
@ -1066,6 +1092,7 @@ function FrigateCameraFeatures({
isActive={snapshotState == "ON"} isActive={snapshotState == "ON"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`} title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/> />
{audioDetectEnabled && ( {audioDetectEnabled && (
<CameraFeatureToggle <CameraFeatureToggle
@ -1075,6 +1102,7 @@ function FrigateCameraFeatures({
isActive={audioState == "ON"} isActive={audioState == "ON"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`} title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")} onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/> />
)} )}
{autotrackingEnabled && ( {autotrackingEnabled && (
@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({
onClick={() => onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
} }
disabled={!cameraEnabled}
/> />
)} )}
<CameraFeatureToggle <CameraFeatureToggle
@ -1099,6 +1128,7 @@ function FrigateCameraFeatures({
isActive={isRecording} isActive={isRecording}
title={`${isRecording ? "Stop" : "Start"} on-demand recording`} title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
onClick={handleEventButtonClick} onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/> />
<DropdownMenu modal={false}> <DropdownMenu modal={false}>

View File

@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState } from "@/api/ws"; import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -108,6 +108,8 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones"); const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones"); const watchedDetectionsZones = form.watch("detections_zones");
const { payload: enabledState, send: sendEnabled } =
useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } = const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera); useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } = const { payload: detectionsState, send: sendDetections } =
@ -252,6 +254,31 @@ export default function CameraSettingsView({
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Streams
</Heading>
<div className="flex flex-row items-center">
<Switch
id="camera-enabled"
className="mr-3"
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">Enable</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Disabling a camera completely stops Frigate's processing of this
camera's streams. Detection, recording, and debugging will be
unavailable.
<br /> <em>Note: This does not disable go2rtc restreams.</em>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
Review Review
</Heading> </Heading>

View File

@ -80,7 +80,7 @@ export default function NotificationView({
return Object.values(config.cameras) return Object.values(config.cameras)
.filter( .filter(
(conf) => (conf) =>
conf.enabled && conf.enabled_in_config &&
conf.notifications && conf.notifications &&
conf.notifications.enabled_in_config, conf.notifications.enabled_in_config,
) )