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

View File

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

View File

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

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):
try:
# TODO: does this copy the numpy array?

View File

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

View File

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

View File

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