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
### `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`
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]] = {}
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] = {}

View File

@ -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

View File

@ -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()

View File

@ -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 = (

View File

@ -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...")

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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;

View File

@ -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<HTMLImageElement | null>(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"
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="size-full rounded-lg border-2 border-muted bg-background_alt text-center md:rounded-2xl" />
)}
{!imageLoaded && enabled ? (
<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}
/>
) : (
<div className="pt-6 text-center">
Camera is disabled in config, no stream or snapshot available!
</div>
<div className="pt-6 text-center">Camera is disabled.</div>
)}
{!hasLoaded && enabled ? (
<div

View File

@ -11,11 +11,15 @@ const variants = {
primary: {
active: "font-bold text-white bg-selected rounded-lg",
inactive: "text-secondary-foreground bg-secondary rounded-lg",
disabled:
"text-secondary-foreground bg-secondary rounded-lg cursor-not-allowed opacity-50",
},
overlay: {
active: "font-bold text-white bg-selected rounded-full",
inactive:
"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;
title: string;
onClick?: () => 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 = (
<div
onClick={onClick}
onClick={disabled ? undefined : onClick}
className={cn(
"flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"],
disabled
? variants[variant].disabled
: variants[variant][isActive ? "active" : "inactive"],
className,
)}
>
<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>
);
@ -54,7 +69,7 @@ export default function CameraFeatureToggle({
if (isDesktop) {
return (
<Tooltip>
<TooltipTrigger>{content}</TooltipTrigger>
<TooltipTrigger disabled={disabled}>{content}</TooltipTrigger>
<TooltipContent side="bottom">
<p>{title}</p>
</TooltipContent>

View File

@ -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}
/>
<VolumeSlider
disabled={!audioState}
disabled={!audioState || !isEnabled}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
@ -280,34 +289,49 @@ export default function LiveContextMenu({
<ContextMenuItem>
<div
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>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
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>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats}
onClick={isEnabled ? toggleStats : undefined}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
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>
@ -315,10 +339,10 @@ export default function LiveContextMenu({
{cameraGroup && cameraGroup !== "default" && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
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>
@ -328,10 +352,10 @@ export default function LiveContextMenu({
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<ContextMenuItem disabled={!isEnabled}>
<div
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>
@ -342,7 +366,7 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuSubTrigger disabled={!isEnabled}>
<div className="flex items-center gap-2">
<span>Notifications</span>
</div>
@ -382,10 +406,15 @@ export default function LiveContextMenu({
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
sendNotification("ON");
sendNotificationSuspend(0);
}}
disabled={!isEnabled}
onClick={
isEnabled
? () => {
sendNotification("ON");
sendNotificationSuspend(0);
}
: undefined
}
>
<div className="flex w-full flex-col gap-2">
{notificationState === "ON" ? (
@ -405,36 +434,71 @@ export default function LiveContextMenu({
Suspend for:
</p>
<div className="space-y-1">
<ContextMenuItem onClick={() => handleSuspend("5")}>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled ? () => handleSuspend("5") : undefined
}
>
5 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("10")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("10")
: undefined
}
>
10 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("30")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("30")
: undefined
}
>
30 minutes
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("60")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("60")
: undefined
}
>
1 hour
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("840")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("840")
: undefined
}
>
12 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("1440")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("1440")
: undefined
}
>
24 hours
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleSuspend("off")}
disabled={!isEnabled}
onClick={
isEnabled
? () => handleSuspend("off")
: undefined
}
>
Until restart
</ContextMenuItem>

View File

@ -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 <ActivityIndicator />;
}
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 = <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 (
<div
ref={cameraRef ?? internalContainerRef}
@ -287,16 +334,18 @@ export default function LivePlayer({
}
}}
>
{((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 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{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 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
{player}
{!offline && !showStillWithoutActivity && !liveReady && (
<ActivityIndicator />
)}
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
@ -344,7 +393,9 @@ export default function LivePlayer({
<div
className={cn(
"absolute inset-0 w-full",
showStillWithoutActivity && !liveReady ? "visible" : "invisible",
showStillWithoutActivity && !liveReady && !isReEnabling
? "visible"
: "invisible",
)}
>
<AutoUpdatingCameraImage
@ -371,6 +422,17 @@ export default function LivePlayer({
</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">
{autoLive &&
!offline &&
@ -378,7 +440,7 @@ export default function LivePlayer({
((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
{offline && showStillWithoutActivity && (
{((offline && showStillWithoutActivity) || !cameraEnabled) && (
<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`}
>

View File

@ -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]);

View File

@ -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,
};
}

View File

@ -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]);

View File

@ -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<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 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}
/>
</div>
)}
@ -244,17 +283,21 @@ type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
cameraEnabledStates: Record<string, boolean>;
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({
)}
<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">
{allCameras.map((item) => (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
const isCameraSettingsPage = currentPage === "camera settings";
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>

View File

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

View File

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

View File

@ -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}
/>
)}
<FrigateCameraFeatures
@ -517,6 +526,7 @@ export default function LiveCameraView({
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
/>
</div>
</TooltipProvider>
@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = {
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
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 (
<>
<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
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
@ -1050,6 +1074,7 @@ function FrigateCameraFeatures({
isActive={detectState == "ON"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@ -1058,6 +1083,7 @@ function FrigateCameraFeatures({
isActive={recordState == "ON"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@ -1066,6 +1092,7 @@ function FrigateCameraFeatures({
isActive={snapshotState == "ON"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
{audioDetectEnabled && (
<CameraFeatureToggle
@ -1075,6 +1102,7 @@ function FrigateCameraFeatures({
isActive={audioState == "ON"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
)}
{autotrackingEnabled && (
@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)}
<CameraFeatureToggle
@ -1099,6 +1128,7 @@ function FrigateCameraFeatures({
isActive={isRecording}
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
<DropdownMenu modal={false}>

View File

@ -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({
<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">
Review
</Heading>

View File

@ -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,
)