Disabled camera output (#16920)

* Fix live cameras not showing on refresh

* Fix live dashboard when birdseye is added

* Handle cameras that are offline / disabled

* Use black instead of green frame

* Fix missing mqtt topics
This commit is contained in:
Nicolas Mowen 2025-03-03 14:05:49 -07:00 committed by GitHub
parent 180b0af3c9
commit 2946c935ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 110 additions and 42 deletions

View File

@ -43,6 +43,11 @@ class MqttClient(Communicator): # type: ignore[misc]
def _set_initial_topics(self) -> None: def _set_initial_topics(self) -> None:
"""Set initial state topics.""" """Set initial state topics."""
for camera_name, camera in self.config.cameras.items(): for camera_name, camera in self.config.cameras.items():
self.publish(
f"{camera_name}/enabled/state",
"ON" if camera.enabled_in_config else "OFF",
retain=True,
)
self.publish( self.publish(
f"{camera_name}/recordings/state", f"{camera_name}/recordings/state",
"ON" if camera.record.enabled_in_config else "OFF", "ON" if camera.record.enabled_in_config else "OFF",
@ -196,6 +201,7 @@ class MqttClient(Communicator): # type: ignore[misc]
# register callbacks # register callbacks
callback_types = [ callback_types = [
"enabled",
"recordings", "recordings",
"snapshots", "snapshots",
"detect", "detect",

View File

@ -390,8 +390,11 @@ class BirdsEyeFrameManager:
def _get_enabled_state(self, camera: str) -> bool: def _get_enabled_state(self, camera: str) -> bool:
"""Fetch the latest enabled state for a camera from ZMQ.""" """Fetch the latest enabled state for a camera from ZMQ."""
_, config_data = self.enabled_subscribers[camera].check_for_update() _, config_data = self.enabled_subscribers[camera].check_for_update()
if config_data: if config_data:
self.config.cameras[camera].enabled = config_data.enabled
return config_data.enabled return config_data.enabled
return self.config.cameras[camera].enabled return self.config.cameras[camera].enabled
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
@ -704,15 +707,17 @@ class BirdsEyeFrameManager:
) -> bool: ) -> bool:
# 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
force_update = False
# disabling birdseye is a little tricky # disabling birdseye is a little tricky
if not camera_config.enabled or not self._get_enabled_state(camera): if not self._get_enabled_state(camera):
# if we've rendered a frame (we have a value for last_active_frame) # 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:
self.cameras[camera]["last_active_frame"] = 0 self.cameras[camera]["last_active_frame"] = 0
force_update = True
return False else:
return False
# update the last active frame for the camera # update the last active frame for the camera
self.cameras[camera]["current_frame"] = frame.copy() self.cameras[camera]["current_frame"] = frame.copy()
@ -723,7 +728,7 @@ class BirdsEyeFrameManager:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
# limit output to 10 fps # limit output to 10 fps
if (now - self.last_output_time) < 1 / 10: if not force_update and (now - self.last_output_time) < 1 / 10:
return False return False
try: try:
@ -735,7 +740,7 @@ class BirdsEyeFrameManager:
print(traceback.format_exc()) print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame # if the frame was updated or the fps is too low, send frame
if updated_frame or (now - self.last_output_time) > 1: if force_update or updated_frame or (now - self.last_output_time) > 1:
self.last_output_time = now self.last_output_time = now
return True return True
return False return False
@ -783,6 +788,22 @@ class Birdseye:
self.converter.start() self.converter.start()
self.broadcaster.start() self.broadcaster.start()
def __send_new_frame(self) -> None:
frame_bytes = self.birdseye_manager.frame.tobytes()
if self.config.birdseye.restream:
self.birdseye_buffer[:] = frame_bytes
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
# drop frames if queue is full
pass
def all_cameras_disabled(self) -> None:
self.birdseye_manager.clear_frame()
self.__send_new_frame()
def write_data( def write_data(
self, self,
camera: str, camera: str,
@ -811,16 +832,7 @@ class Birdseye:
frame_time, frame_time,
frame, frame,
): ):
frame_bytes = self.birdseye_manager.frame.tobytes() self.__send_new_frame()
if self.config.birdseye.restream:
self.birdseye_buffer[:] = frame_bytes
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
# drop frames if queue is full
pass
def stop(self) -> None: def stop(self) -> None:
self.config_subscriber.stop() self.config_subscriber.stop()

View File

@ -1,12 +1,12 @@
"""Handle outputting raw frigate frames""" """Handle outputting raw frigate frames"""
import datetime
import logging import logging
import multiprocessing as mp import multiprocessing as mp
import os import os
import shutil import shutil
import signal import signal
import threading import threading
from typing import Optional
from wsgiref.simple_server import make_server from wsgiref.simple_server import make_server
from setproctitle import setproctitle from setproctitle import setproctitle
@ -25,11 +25,43 @@ from frigate.const import CACHE_DIR, CLIPS_DIR
from frigate.output.birdseye import Birdseye from frigate.output.birdseye import Birdseye
from frigate.output.camera import JsmpegCamera from frigate.output.camera import JsmpegCamera
from frigate.output.preview import PreviewRecorder from frigate.output.preview import PreviewRecorder
from frigate.util.image import SharedMemoryFrameManager from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def check_disabled_camera_update(
config: FrigateConfig,
birdseye: Birdseye | None,
previews: dict[str, PreviewRecorder],
write_times: dict[str, float],
) -> None:
"""Check if camera is disabled / offline and needs an update."""
now = datetime.datetime.now().timestamp()
has_enabled_camera = False
for camera, last_update in write_times.items():
if config.cameras[camera].enabled:
has_enabled_camera = True
if now - last_update > 1:
# last camera update was more than one second ago
# need to send empty data to updaters because current
# frame is now out of date
frame = get_blank_yuv_frame(
config.cameras[camera].detect.width,
config.cameras[camera].detect.height,
)
if birdseye:
birdseye.write_data(camera, [], [], now, frame)
previews[camera].write_data([], [], now, frame)
if not has_enabled_camera and birdseye:
birdseye.all_cameras_disabled()
def output_frames( def output_frames(
config: FrigateConfig, config: FrigateConfig,
): ):
@ -67,10 +99,11 @@ def output_frames(
} }
jsmpeg_cameras: dict[str, JsmpegCamera] = {} jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Optional[Birdseye] = None birdseye: Birdseye | None = None
preview_recorders: dict[str, PreviewRecorder] = {} preview_recorders: dict[str, PreviewRecorder] = {}
preview_write_times: dict[str, float] = {} preview_write_times: dict[str, float] = {}
failed_frame_requests: dict[str, int] = {} failed_frame_requests: dict[str, int] = {}
last_disabled_cam_check = datetime.datetime.now().timestamp()
move_preview_frames("cache") move_preview_frames("cache")
@ -89,13 +122,23 @@ def output_frames(
def get_enabled_state(camera: str) -> bool: def get_enabled_state(camera: str) -> bool:
_, config_data = enabled_subscribers[camera].check_for_update() _, config_data = enabled_subscribers[camera].check_for_update()
if config_data: if config_data:
config.cameras[camera].enabled = config_data.enabled
return config_data.enabled return config_data.enabled
# default
return config.cameras[camera].enabled 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)
now = datetime.datetime.now().timestamp()
if now - last_disabled_cam_check > 5:
# check disabled cameras every 5 seconds
last_disabled_cam_check = now
check_disabled_camera_update(
config, birdseye, preview_recorders, preview_write_times
)
if not topic: if not topic:
continue continue
@ -151,23 +194,10 @@ def output_frames(
) )
# send frames for low fps recording # send frames for low fps recording
generated_preview = preview_recorders[camera].write_data( preview_recorders[camera].write_data(
current_tracked_objects, motion_boxes, frame_time, frame current_tracked_objects, motion_boxes, frame_time, frame
) )
preview_write_times[camera] = frame_time preview_write_times[camera] = frame_time
# if another camera generated a preview,
# check for any cameras that are currently offline
# and need to generate a preview
if generated_preview:
logger.debug(
"Checking for offline cameras because another camera generated a preview."
)
for camera, time in preview_write_times.copy().items():
if time != 0 and frame_time - time > 10:
preview_recorders[camera].flag_offline(frame_time)
preview_write_times[camera] = frame_time
frame_manager.close(frame_name) frame_manager.close(frame_name)
move_preview_frames("clips") move_preview_frames("clips")

View File

@ -632,6 +632,22 @@ def copy_yuv_to_position(
) )
def get_blank_yuv_frame(width: int, height: int) -> np.ndarray:
"""Creates a black YUV 4:2:0 frame."""
yuv_height = height * 3 // 2
yuv_frame = np.zeros((yuv_height, width), dtype=np.uint8)
uv_height = height // 2
# The U and V planes are stored after the Y plane.
u_start = height # U plane starts right after Y plane
v_start = u_start + uv_height // 2 # V plane starts after U plane
yuv_frame[u_start : u_start + uv_height, :width] = 128
yuv_frame[v_start : v_start + uv_height, :width] = 128
return yuv_frame
def yuv_region_2_yuv(frame, region): def yuv_region_2_yuv(frame, region):
try: try:
# TODO: does this copy the numpy array? # TODO: does this copy the numpy array?

View File

@ -200,7 +200,7 @@ export default function LivePlayer({
// enabled states // enabled states
const [isReEnabling, setIsReEnabling] = useState(false); const [isReEnabling, setIsReEnabling] = useState(false);
const prevCameraEnabledRef = useRef(cameraEnabled); const prevCameraEnabledRef = useRef(cameraEnabled ?? true);
useEffect(() => { useEffect(() => {
if (!prevCameraEnabledRef.current && cameraEnabled) { if (!prevCameraEnabledRef.current && cameraEnabled) {

View File

@ -396,10 +396,12 @@ export default function DraggableGridLayout({
const initialVolumeStates: VolumeState = {}; const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { if (groupSettings) {
initialAudioStates[camera] = cameraSettings.playAudio ?? false; Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialVolumeStates[camera] = cameraSettings.volume ?? 1; initialAudioStates[camera] = cameraSettings.playAudio ?? false;
}); initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
}); });
setAudioStates(initialAudioStates); setAudioStates(initialAudioStates);

View File

@ -268,10 +268,12 @@ export default function LiveDashboardView({
const initialVolumeStates: VolumeState = {}; const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { if (groupSettings) {
initialAudioStates[camera] = cameraSettings.playAudio ?? false; Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialVolumeStates[camera] = cameraSettings.volume ?? 1; initialAudioStates[camera] = cameraSettings.playAudio ?? false;
}); initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
}
}); });
setAudioStates(initialAudioStates); setAudioStates(initialAudioStates);