Camera Health Status (#19709)

* Send status of camera streams to mqtt

* Update docs

* Formatting

* Fix frontend querying fps
This commit is contained in:
Nicolas Mowen 2025-08-22 06:42:36 -06:00 committed by GitHub
parent 539c760953
commit 9dd7ead462
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 53 additions and 4 deletions

View File

@ -238,6 +238,14 @@ Topic with current state of notifications. Published values are `ON` and `OFF`.
## Frigate Camera Topics ## Frigate Camera Topics
### `frigate/<camera_name>/<role>/status`
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are:
- `online`: Stream is running and being processed
- `offline`: Stream is offline and is being restarted
- `disabled`: Camera is currently disabled
### `frigate/<camera_name>/<object_name>` ### `frigate/<camera_name>/<object_name>`
Publishes the count of objects for the camera for use as a sensor in Home Assistant. Publishes the count of objects for the camera for use as a sensor in Home Assistant.

View File

@ -356,6 +356,7 @@ class AudioEventMaintainer(threading.Thread):
self.chunk_size, self.chunk_size,
self.audio_listener, self.audio_listener,
) )
self.requestor.send_data(f"{self.camera_config.name}/status/audio", "online")
def read_audio(self) -> None: def read_audio(self) -> None:
def log_and_restart() -> None: def log_and_restart() -> None:
@ -371,6 +372,9 @@ class AudioEventMaintainer(threading.Thread):
if not chunk: if not chunk:
if self.audio_listener.poll() is not None: if self.audio_listener.poll() is not None:
self.requestor.send_data(
f"{self.camera_config.name}/status/audio", "offline"
)
self.logger.error("ffmpeg process is not running, restarting...") self.logger.error("ffmpeg process is not running, restarting...")
log_and_restart() log_and_restart()
return return
@ -396,6 +400,9 @@ class AudioEventMaintainer(threading.Thread):
) )
self.start_or_restart_ffmpeg() self.start_or_restart_ffmpeg()
else: else:
self.requestor.send_data(
f"{self.camera_config.name}/status/audio", "disabled"
)
self.logger.debug( self.logger.debug(
f"Disabling audio detections for {self.camera_config.name}, ending events" f"Disabling audio detections for {self.camera_config.name}, ending events"
) )

View File

@ -71,7 +71,7 @@ def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger):
def start_or_restart_ffmpeg( def start_or_restart_ffmpeg(
ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
): ) -> sp.Popen[Any]:
if ffmpeg_process is not None: if ffmpeg_process is not None:
stop_ffmpeg(ffmpeg_process, logger) stop_ffmpeg(ffmpeg_process, logger)
@ -96,7 +96,7 @@ def start_or_restart_ffmpeg(
def capture_frames( def capture_frames(
ffmpeg_process, ffmpeg_process: sp.Popen[Any],
config: CameraConfig, config: CameraConfig,
shm_frame_count: int, shm_frame_count: int,
frame_index: int, frame_index: int,
@ -107,7 +107,7 @@ def capture_frames(
skipped_fps: Value, skipped_fps: Value,
current_frame: Value, current_frame: Value,
stop_event: MpEvent, stop_event: MpEvent,
): ) -> None:
frame_size = frame_shape[0] * frame_shape[1] frame_size = frame_shape[0] * frame_shape[1]
frame_rate = EventsPerSecond() frame_rate = EventsPerSecond()
frame_rate.start() frame_rate.start()
@ -196,6 +196,7 @@ class CameraWatchdog(threading.Thread):
self.config_subscriber = CameraConfigUpdateSubscriber( self.config_subscriber = CameraConfigUpdateSubscriber(
None, {config.name: config}, [CameraConfigUpdateEnum.enabled] None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
) )
self.requestor = InterProcessRequestor()
self.was_enabled = self.config.enabled self.was_enabled = self.config.enabled
def _update_enabled_state(self) -> bool: def _update_enabled_state(self) -> bool:
@ -245,6 +246,14 @@ class CameraWatchdog(threading.Thread):
else: else:
self.logger.debug(f"Disabling camera {self.config.name}") self.logger.debug(f"Disabling camera {self.config.name}")
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
# update camera status
self.requestor.send_data(
f"{self.config.name}/status/detect", "disabled"
)
self.requestor.send_data(
f"{self.config.name}/status/record", "disabled"
)
self.was_enabled = enabled self.was_enabled = enabled
continue continue
@ -254,6 +263,7 @@ class CameraWatchdog(threading.Thread):
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
if not self.capture_thread.is_alive(): if not self.capture_thread.is_alive():
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self.camera_fps.value = 0 self.camera_fps.value = 0
self.logger.error( self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}." f"Ffmpeg process crashed unexpectedly for {self.config.name}."
@ -263,6 +273,9 @@ class CameraWatchdog(threading.Thread):
self.fps_overflow_count += 1 self.fps_overflow_count += 1
if self.fps_overflow_count == 3: if self.fps_overflow_count == 3:
self.requestor.send_data(
f"{self.config.name}/status/detect", "offline"
)
self.fps_overflow_count = 0 self.fps_overflow_count = 0
self.camera_fps.value = 0 self.camera_fps.value = 0
self.logger.info( self.logger.info(
@ -270,6 +283,7 @@ class CameraWatchdog(threading.Thread):
) )
self.reset_capture_thread(drain_output=False) self.reset_capture_thread(drain_output=False)
elif now - self.capture_thread.current_frame.value > 20: elif now - self.capture_thread.current_frame.value > 20:
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self.camera_fps.value = 0 self.camera_fps.value = 0
self.logger.info( self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..." f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
@ -277,6 +291,7 @@ class CameraWatchdog(threading.Thread):
self.reset_capture_thread() self.reset_capture_thread()
else: else:
# process is running normally # process is running normally
self.requestor.send_data(f"{self.config.name}/status/detect", "online")
self.fps_overflow_count = 0 self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes: for p in self.ffmpeg_other_processes:
@ -302,13 +317,27 @@ class CameraWatchdog(threading.Thread):
p["logpipe"], p["logpipe"],
ffmpeg_process=p["process"], ffmpeg_process=p["process"],
) )
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role}", "offline"
)
continue continue
else: else:
self.requestor.send_data(
f"{self.config.name}/status/record", "online"
)
p["latest_segment_time"] = latest_segment_time p["latest_segment_time"] = latest_segment_time
if poll is None: if poll is None:
continue continue
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role}", "offline"
)
p["logpipe"].dump() p["logpipe"].dump()
p["process"] = start_or_restart_ffmpeg( p["process"] = start_or_restart_ffmpeg(
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]

View File

@ -32,7 +32,12 @@ export default function CameraMetrics({
// stats // stats
const { data: initialStats } = useSWR<FrigateStats[]>( const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "cpu_usages,cameras,detection_fps,service" }], [
"stats/history",
{
keys: "cpu_usages,cameras,camera_fps,detection_fps,skipped_fps,service",
},
],
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
}, },