Files
frigate/frigate/stats/hardware.py
T
Josh HawkinsandGitHub a1c8bf99a7 Miscellaneous fixes (#24498)
* fix auto quality recovery after a downswitch

The downswitch callback armed the upswitch probe, but `triggerDownswitch` reset the stall history right after the callback returned, which disarmed it again. Auto quality stayed on the sub stream until the next chunk boundary no matter how much the connection recovered. The governor now arms the probe itself after the reset.

The chunk boundary effect also ran on mount, so when coverage was already cached it immediately undid the low quality cold start the seed effect had just picked. It now only runs when the chunk actually changes.

* fix recording playback quality switches and silent codec failures

A quality switch changes `bufferLength` a commit before the new source arrives, and since it was a dependency of the hls.js setup effect, the player rebuilt once on the outgoing playlist (jumping back to its original `startPosition`) and again on the new one. The buffer length now updates the running instance's config instead.

A fatal codec error with no lower quality stream to fall back to did nothing at all, so playback just sat there with no message. It now shows the playback failure toast, which is also limited to once per source since hls.js and the video element can both report the same failure.

* retry failed WebRTC probes and skip offline streams

The connectivity probe only ever tried the first go2rtc stream and cached its result for the whole page session, so a single offline camera at the top of the go2rtc config marked WebRTC unreachable for every camera until a reload, as did any brief network hiccup. The probe now starts with the stream being viewed and moves on to the next one when go2rtc reports that it can't open the stream's source. A failed result is only reused for 30 seconds, and it's retried on the next mount or when the page becomes visible again.

* fix two-way talk on cameras with AAC audio

The mic button was enabled whenever WebRTC was globally available, but the live view only switched to the WebRTC player when the stream itself qualified for WebRTC, and AAC playback audio disqualifies it. On most cameras the mic showed as on while nothing was sent. Two-way talk only needs the stream's video to connect since the backchannel is sent, not received, so AAC playback audio no longer blocks it. The mic is also turned off when a stream switch makes talk unavailable.

* keep Frigate+ model references when saving the models section

`/api/config` served a Frigate+ model's path as the resolved `/config/model_cache/<id>` file, and since the models list is saved whole, editing any model in the settings UI wrote that cache path back to the config in place of `plus://<id>`. After a restart the model loaded as a custom model with the default labelmap. The config API now reports the `plus://<id>` reference the model was configured with, and the models section drops the fields the Frigate+ model info supplies (size, tensor, pixel format, dtype, and type) instead of pinning them in the config.

* allow models to share shareable detection hardware

The hardware picker treated every device another model listed as taken, so a second model couldn't pick an Intel GPU or the CPU that the first one already used. The backend only rejects reuse of devices that can't be shared (Coral, MemryX), so the picker now matches that and only marks exclusive units as claimed.

* fix model card state and camera counts in the models editor

Model cards were keyed by index, so deleting one handed its state (such as the selected model source tab) to the card after it. Cards are keyed by scene now, which is unique per model.

The camera count on each card also ignored the backend's fallback to the `all` model, so a camera whose detect scene had no model of its own wasn't counted anywhere. It's counted under `all` now, which also feeds the recommended detector count.

* share a unit's temperature across repeated detector devices

Detector temperatures were matched to units by counting detectors of each type, so a device listed twice to run a second inference process (`hailo:PCIe` and `hailo:PCIe#2`) showed the next unit's temperature, or none at all. Distinct devices are numbered now and repeats share their unit's reading.

* update monitored hardware after a runtime config swap

`swap_runtime_config` rebound the stats emitter to the new config but not its `HardwareStats`, which kept polling hardware for the old config and applied camera updates to the discarded object. It now follows the swap along with its camera update subscriber.

* time out model downloads that never respond

`download_from_url` had no timeout, so a proxy or server that accepted the connection and never answered hung the download forever, including runtime downloads during startup. Connect and read timeouts now fail it like any other download error. The read timeout applies per socket read, so large models still finish.

* resolve segment start times in segment order

A camera stream's cached segments are probed concurrently, and each one chained its start off `last_segment_end` as soon as its own probe finished. When segments backed up in the cache and a later probe finished first, it chained off the wrong segment and the earlier one then moved `last_segment_end` backwards, so rows lost their exact adjacency. Probes still run concurrently, but each segment now waits for the one before it to settle its start before resolving its own.

* plan exports from the same coverage the vod route serves

The vod manifest nulls video-only glitch rows on audio-bearing streams, but exports planned their stream runs from the raw coverage, so a glitch row could produce a mixed-stream file or a 404 that failed the export. `null_audio_glitches` now works out each stream's audio composition itself, and exports go through it like the manifest and its realized timelines do.

An unstaged auto export also paged its playlist and chapters over main whenever main had any rows in range, even when the manifest served the range from sub and main only contributed glitches or slivers at the edges. It now reads the rows of the stream its single run actually uses.

* keep staged export chapters aligned across stream hand-offs

Each staged run of a mixed-stream export is rendered from its own pinned vod playlist, and that playlist's first clip snaps back to the preceding keyframe, so every staged file runs up to a GOP longer than its slice of the merged timeline. Chapters were placed on the merged timeline, so they drifted further from the video at every hand-off. Chapter windows for staged exports are now planned the same way each run's playlist is, carrying that keyframe lead-in into the offsets.

* clarify which hardware units only one model can use

* add e2e tests for shareable hardware and Frigate+ model saves

* only show the path field for a Frigate+ model without an API key

Without `PLUS_API_KEY` the models editor has no Frigate+ tab, so a `plus://` model showed every custom model field. The size, format, type, and labelmap fields are all supplied by the Frigate+ model info and ignored for a Frigate+ model, so editing them did nothing. Only the path is shown now, which still lets the model be switched to a custom one.

* keep a configured input_dtype when saving a Frigate+ model

The backend only overwrites `input_dtype` when the Frigate+ model info supplies `inputDataType`, which older models don't, so a configured dtype still matters for them. Saving the models section was dropping it along with the fields the backend always overwrites.

* probe every go2rtc stream until one isn't refused

The probe stopped after three streams, so with three offline cameras ahead of a working one, WebRTC was marked unreachable everywhere. It only moves past a stream when go2rtc refuses it, which is quick, and a stream that hangs still ends the probe at its timeout, so the cap bought nothing.

* only reuse a failed WebRTC probe for the stream it started from

A failed probe was reused for 30 seconds by every caller, so a camera whose stream timed out kept WebRTC unavailable for the next camera viewed, including one opened while that probe was still running. A pass still counts for every stream since it proves the connection, but a failure is only reused by probes that start from the same stream.

* strip input_dtype from Frigate+ models again

A Frigate+ model's config comes entirely from its model info, and a missing `inputDataType` means the `int` default. Keeping `input_dtype` meant switching from a custom model with `input_dtype: float` to a Frigate+ model carried the stale dtype over, with the field hidden so it couldn't be corrected.
2026-09-28 13:45:05 -06:00

486 lines
15 KiB
Python

"""Config driven hardware usage collection for stats."""
import logging
import os
import time
from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from functools import cache, partial
from glob import glob
from typing import Any
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.config.classification import SemanticSearchModelEnum
from frigate.const import FFMPEG_HWACCEL_AMF, FFMPEG_HWACCEL_VULKAN
from frigate.detectors.hardware import DEV_ROOT, hardware_prober
from frigate.util.services import (
get_amd_gpu_stats,
get_axcl_npu_stats,
get_cpu_stats,
get_hailo_temps,
get_intel_gpu_stats,
get_jetson_stats,
get_nvidia_gpu_stats,
get_openvino_npu_stats,
get_rockchip_gpu_stats,
get_rockchip_npu_stats,
is_vaapi_amd_driver,
)
logger = logging.getLogger(__name__)
HARDWARE_ERROR_COOLDOWN_SECONDS = 3600
POLL_TIMEOUT_SECONDS = 10
# hardware names polled here, in the order used to resolve a generic GPU demand
GPU_HARDWARE_KEYS = {
"onnx:nvidia": "nvidia",
"tensorrt": "jetson",
"onnx:amd": "amd_gpu",
"openvino:GPU": "intel_gpu",
"rknn": "rockchip",
}
@dataclass
class HardwarePollResult:
"""Usage entries from one hardware poll, split by stats bucket."""
gpu: dict[str, dict[str, Any]] = field(default_factory=dict)
npu: dict[str, dict[str, Any]] = field(default_factory=dict)
ok: bool = True
@cache
def _is_amd_vaapi() -> bool:
"""Cached vaapi driver check, the driver cannot change at runtime."""
return is_vaapi_amd_driver()
def _present_hardware() -> set[str]:
return {hardware.key for hardware in hardware_prober.probe()}
def _gpu_device_nodes_exist(name: str) -> bool:
"""Whether the GPU's device nodes are visible to this container.
The prober reads /proc and /sys, which show the host kernel's hardware
even when a device was not passed into the container, so a GPU is only
usable when its device nodes exist too.
"""
if name == "nvidia":
return bool(glob(f"{DEV_ROOT}/nvidia*"))
elif name in ("amd_gpu", "intel_gpu"):
return bool(glob(f"{DEV_ROOT}/dri/*"))
return True
def _present_gpu() -> str | None:
"""The hardware name of the first usable GPU found on this system, if any."""
present = _present_hardware()
for key, name in GPU_HARDWARE_KEYS.items():
if key in present and _gpu_device_nodes_exist(name):
return name
return None
def _poll_nvidia(config: FrigateConfig) -> HardwarePollResult:
nvidia_usage = get_nvidia_gpu_stats()
if not nvidia_usage:
return HardwarePollResult(
gpu={"nvidia-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
)
gpu: dict[str, dict[str, Any]] = {}
for entry in nvidia_usage.values():
gpu[entry["name"]] = {
"vendor": "nvidia",
"gpu": str(round(float(entry["gpu"]), 2)) + "%",
"mem": str(round(float(entry["mem"]), 2)) + "%",
"enc": str(round(float(entry["enc"]), 2)) + "%",
"dec": str(round(float(entry["dec"]), 2)) + "%",
"temp": str(entry["temp"]),
}
return HardwarePollResult(gpu=gpu)
def _poll_jetson(config: FrigateConfig) -> HardwarePollResult:
jetson_usage = get_jetson_stats()
if not jetson_usage:
return HardwarePollResult(
gpu={"jetson-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
)
return HardwarePollResult(gpu={"jetson-gpu": {"vendor": "nvidia", **jetson_usage}})
def _poll_intel_gpu(config: FrigateConfig) -> HardwarePollResult:
intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device)
if not intel_usage:
return HardwarePollResult(
gpu={"intel-gpu": {"vendor": "intel", "gpu": "", "mem": ""}}, ok=False
)
gpu: dict[str, dict[str, Any]] = {}
for entry in intel_usage.values():
name = entry.pop("name")
gpu[name] = entry
return HardwarePollResult(gpu=gpu)
def _poll_amd_gpu(config: FrigateConfig) -> HardwarePollResult:
amd_usage = get_amd_gpu_stats()
if not amd_usage:
return HardwarePollResult(
gpu={"amd-vaapi": {"vendor": "amd", "gpu": "", "mem": ""}}, ok=False
)
return HardwarePollResult(gpu={"amd-vaapi": {"vendor": "amd", **amd_usage}})
def _poll_rockchip(config: FrigateConfig) -> HardwarePollResult:
result = HardwarePollResult()
rga_usage = get_rockchip_gpu_stats()
if rga_usage:
result.gpu["rockchip"] = {"vendor": "rockchip", **rga_usage}
npu_usage = get_rockchip_npu_stats()
if npu_usage:
result.npu["rockchip"] = npu_usage
result.ok = bool(rga_usage or npu_usage)
return result
def _poll_rpi(config: FrigateConfig) -> HardwarePollResult:
# RPi v4l2m2m is currently not able to get usage stats
return HardwarePollResult(
gpu={"rpi-v4l2m2m": {"vendor": "rpi", "gpu": "", "mem": ""}}
)
def _poll_intel_npu(config: FrigateConfig) -> HardwarePollResult:
npu_usage = get_openvino_npu_stats()
if not npu_usage:
return HardwarePollResult(ok=False)
return HardwarePollResult(npu={"openvino": npu_usage})
def _poll_axengine(config: FrigateConfig) -> HardwarePollResult:
npu_usage = get_axcl_npu_stats()
if not npu_usage:
return HardwarePollResult(ok=False)
return HardwarePollResult(npu={"axengine": npu_usage})
POLLERS: dict[str, Callable[[FrigateConfig], HardwarePollResult]] = {
"nvidia": _poll_nvidia,
"jetson": _poll_jetson,
"intel_gpu": _poll_intel_gpu,
"amd_gpu": _poll_amd_gpu,
"rockchip": _poll_rockchip,
"rpi": _poll_rpi,
"intel_npu": _poll_intel_npu,
"axengine": _poll_axengine,
}
def _hwaccel_hardware(args: str) -> str | None:
"""Map an ffmpeg hwaccel arg string (preset or raw args) to a hardware name."""
if "cuvid" in args or "nvidia" in args:
return "nvidia"
elif "nvmpi" in args or "jetson" in args:
return "jetson"
elif "qsv" in args:
return "intel_gpu"
elif FFMPEG_HWACCEL_AMF in args or "amf" in args:
return "amd_gpu"
elif "vaapi" in args:
return "amd_gpu" if _is_amd_vaapi() else "intel_gpu"
elif "preset-rk" in args or "rkmpp" in args:
return "rockchip"
elif "v4l2m2m" in args or "rpi" in args:
return "rpi"
elif FFMPEG_HWACCEL_VULKAN in args or "vulkan" in args:
# vulkan is vendor neutral, attribute it to whichever GPU is present
return _present_gpu()
return None
class HardwareStats:
"""Catalogs and polls all hardware relevant to the config."""
def __init__(self, config: FrigateConfig) -> None:
self.config = config
# hardware name -> bound poll function; the catalog of what to monitor
self._monitored: dict[str, Callable[[], HardwarePollResult]] = {}
self._errors: dict[str, float] = {}
self._executor = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="hw_stats"
)
self._config_subscriber = CameraConfigUpdateSubscriber(
config,
config.cameras,
[
CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.audio_transcription,
CameraConfigUpdateEnum.semantic_search,
CameraConfigUpdateEnum.face_recognition,
CameraConfigUpdateEnum.lpr,
],
)
self.update_config()
def set_config(self, config: FrigateConfig) -> None:
"""Follow a runtime config swap and recalculate the monitored hardware.
The camera update subscriber has to follow too, or later camera updates
would land on the discarded config.
"""
self.config = config
self._config_subscriber.config = config
self._config_subscriber.camera_configs = config.cameras
self.update_config()
def update_config(self) -> None:
"""Recalculate all hardware that needs to be monitored from the config."""
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
monitored: dict[str, Callable[[], HardwarePollResult]] = {}
for name in sorted(names):
if name == "intel_gpu" and not self.config.telemetry.stats.intel_gpu_stats:
continue
if name == "amd_gpu" and not self.config.telemetry.stats.amd_gpu_stats:
continue
monitored[name] = partial(POLLERS[name], self.config)
self._monitored = monitored
logger.debug("Monitoring hardware for stats: %s", list(monitored))
def update_stats(self, all_stats: dict[str, Any]) -> None:
"""Poll the monitored hardware concurrently and fill usage stats."""
if self._config_subscriber.check_for_updates():
self.update_config()
now = time.monotonic()
hardware_futures: dict[Future[HardwarePollResult], str] = {}
for name, poll in self._monitored.items():
last_error = self._errors.get(name)
if last_error is not None:
if now - last_error < HARDWARE_ERROR_COOLDOWN_SECONDS:
continue
self._errors.pop(name, None)
hardware_futures[self._executor.submit(poll)] = name
cpu_future = self._executor.submit(get_cpu_stats)
futures: list[Future[Any]] = [*hardware_futures, cpu_future]
done, _ = wait(futures, timeout=POLL_TIMEOUT_SECONDS)
gpu_usages: dict[str, dict[str, Any]] = {}
npu_usages: dict[str, dict[str, Any]] = {}
for future, name in hardware_futures.items():
if future not in done:
logger.warning("Timed out collecting %s stats", name)
self._errors[name] = time.monotonic()
continue
try:
result = future.result()
except Exception:
logger.exception("Failed to collect %s stats", name)
self._errors[name] = time.monotonic()
continue
gpu_usages.update(result.gpu)
npu_usages.update(result.npu)
if not result.ok:
self._errors[name] = time.monotonic()
if gpu_usages:
all_stats["gpu_usages"] = gpu_usages
if npu_usages:
all_stats["npu_usages"] = npu_usages
if cpu_future in done:
try:
cpu_stats = cpu_future.result()
except Exception:
logger.exception("Failed to collect cpu stats")
else:
if cpu_stats:
all_stats["cpu_usages"] = cpu_stats
def stop(self) -> None:
self._config_subscriber.stop()
self._executor.shutdown(wait=False, cancel_futures=True)
def _scan_ffmpeg(self) -> set[str]:
"""Hardware used by camera hwaccel args (presets or raw ffmpeg args)."""
hwaccel_args: list[str] = []
for camera in self.config.cameras.values():
args = camera.ffmpeg.hwaccel_args
if isinstance(args, list):
args = " ".join(args)
if args and args not in hwaccel_args:
hwaccel_args.append(args)
for stream_input in camera.ffmpeg.inputs:
args = stream_input.hwaccel_args
if isinstance(args, list):
args = " ".join(args)
if args and args not in hwaccel_args:
hwaccel_args.append(args)
names: set[str] = set()
for args in hwaccel_args:
name = _hwaccel_hardware(args)
if name is not None:
names.add(name)
return names
def _scan_detectors(self) -> set[str]:
"""Hardware used by the configured detection models."""
names: set[str] = set()
for model in self.config.models:
for spec in self.config.devices_for_model(model):
if spec.detector == "rknn":
names.add("rockchip")
elif spec.detector == "axengine":
names.add("axengine")
elif spec.detector == "tensorrt":
names.add("jetson")
elif spec.detector == "openvino":
if spec.device == "NPU":
names.add("intel_npu")
elif spec.device is None or spec.device.startswith("GPU"):
names.add("intel_gpu")
elif spec.detector == "onnx":
gpu = _present_gpu()
if gpu is not None:
names.add(gpu)
return names
def _scan_enrichments(self) -> set[str]:
"""Hardware used by enabled enrichments, resolved to the present GPU."""
config = self.config
names: set[str] = set()
gpu = _present_gpu()
if gpu is not None:
semantic = config.semantic_search
if (
semantic.enabled
# GenAI providers run remotely and use no local hardware
and (
semantic.model is None
or isinstance(semantic.model, SemanticSearchModelEnum)
)
and (
semantic.device
or ("GPU" if semantic.model_size == "large" else "CPU")
)
!= "CPU"
):
names.add(gpu)
if (
config.face_recognition.enabled
and (config.face_recognition.device or "GPU") != "CPU"
):
names.add(gpu)
if config.lpr.enabled and (config.lpr.device or "AUTO") != "CPU":
names.add(gpu)
# audio transcription runs on CUDA only, and the device is global only
transcription = config.audio_transcription
transcription_enabled = transcription.enabled or any(
camera.audio_transcription.enabled for camera in config.cameras.values()
)
if (
transcription_enabled
and transcription.device == "GPU"
and "onnx:nvidia" in _present_hardware()
and _gpu_device_nodes_exist("nvidia")
):
names.add("nvidia")
return names
def read_temperature(path: str) -> float | None:
"""Read a sysfs temperature file, converting millidegrees to degrees.
Returns None when the file does not exist.
"""
if os.path.isfile(path):
with open(path) as f:
line = f.readline().strip()
return int(line) / 1000
return None
def get_hardware_temperatures(detector_type: str) -> list[float | None]:
"""Per unit temperatures for a detector type, in stable device order."""
if detector_type == "edgetpu":
# PCIe Corals expose a temperature through the apex driver
base = "/sys/class/apex/"
if os.path.isdir(base):
return [
read_temperature(os.path.join(base, apex, "temp"))
for apex in sorted(os.listdir(base))
]
elif detector_type == "hailo":
hailo_temps = get_hailo_temps()
return [hailo_temps[name] for name in sorted(hailo_temps.keys())]
return []