From c406a93d3d123fd75173f770ffc9b0603195f25c Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:49:05 -0500
Subject: [PATCH] Miscellaneous fixes (0.18 beta) (#23725)
---
.gitignore | 1 +
docs/docs/frigate/installation.md | 27 ++-
frigate/api/app.py | 6 +-
frigate/camera/maintainer.py | 4 +-
frigate/camera/state.py | 4 +-
frigate/comms/dispatcher.py | 4 +
frigate/config/camera/updater.py | 3 +
frigate/output/birdseye.py | 26 ++-
frigate/ptz/autotrack.py | 50 ++++-
frigate/ptz/onvif.py | 19 +-
frigate/test/test_birdseye.py | 71 ++++++-
frigate/test/test_gpu_stats.py | 150 +++++++++++++-
frigate/test/test_ptz_autotrack.py | 130 ++++++++++++
frigate/test/test_ptz_onvif.py | 147 ++++++++++++++
frigate/util/services.py | 188 +++++++++++++++---
web/public/locales/en/views/system.json | 7 +-
.../sectionExtras/BirdseyeCameraReorder.tsx | 1 +
web/src/components/icons/IconPicker.tsx | 6 +-
web/src/components/overlay/ExportDialog.tsx | 7 +-
web/src/views/system/GeneralMetrics.tsx | 84 +-------
20 files changed, 789 insertions(+), 146 deletions(-)
create mode 100644 frigate/test/test_ptz_autotrack.py
create mode 100644 frigate/test/test_ptz_onvif.py
diff --git a/.gitignore b/.gitignore
index 7c97a23a0e..70ec5ae76f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ config/*
models
*.mp4
*.db
+*.db-*
*.csv
frigate/version.py
web/build
diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md
index 5cdbc13627..14c85ec4f3 100644
--- a/docs/docs/frigate/installation.md
+++ b/docs/docs/frigate/installation.md
@@ -78,7 +78,7 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
-The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose).
+The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
@@ -86,6 +86,30 @@ The Frigate container also stores logs in shm, which can take up to **40MB**, so
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
+### Process and file limits
+
+Frigate runs many processes and opens a number of shared memory files. Installs with a large number of cameras can exceed the default limits your container runtime applies.
+
+Hitting the PID limit logs `RuntimeError: can't start new thread`, often followed by a "Bus error" that makes it look like an shm sizing problem. Compare the current count against the max from inside the container:
+
+```bash
+cat /sys/fs/cgroup/pids.current
+cat /sys/fs/cgroup/pids.max
+```
+
+If these are close, raise the limit with [`--pids-limit`](https://docs.docker.com/engine/containers/resource_constraints/) (or `service.pids_limit` in Docker Compose).
+
+Running out of file descriptors logs `OSError: [Errno 24] Too many open files`. Raise the limit in Docker Compose:
+
+```yaml
+services:
+ frigate:
+ ulimits:
+ nofile:
+ soft: 65535
+ hard: 65535
+```
+
## Extra Steps for Specific Hardware
The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section.
@@ -484,7 +508,6 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
-
```yaml
diff --git a/frigate/api/app.py b/frigate/api/app.py
index ac839158e2..49b555606d 100644
--- a/frigate/api/app.py
+++ b/frigate/api/app.py
@@ -971,7 +971,11 @@ def config_set(request: Request, body: AppConfigSetBody):
content=(
{
"success": True,
- "message": "Config successfully updated, restart to apply",
+ "message": (
+ "Config successfully updated"
+ if body.requires_restart == 0
+ else "Config successfully updated, restart to apply"
+ ),
}
),
status_code=200,
diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py
index ea8df7bff0..9f63ead2da 100644
--- a/frigate/camera/maintainer.py
+++ b/frigate/camera/maintainer.py
@@ -117,7 +117,9 @@ class CameraMaintainer(threading.Thread):
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
- self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
+ self.ptz_metrics[name] = PTZMetrics(
+ autotracker_enabled=config.onvif.autotracking.enabled
+ )
self.region_grids[name] = get_camera_regions_grid(
name,
config.detect,
diff --git a/frigate/camera/state.py b/frigate/camera/state.py
index c0d0f8b7ff..fed819154e 100644
--- a/frigate/camera/state.py
+++ b/frigate/camera/state.py
@@ -111,9 +111,9 @@ class CameraState:
# draw thicker box around ptz autotracked object
if (
self.camera_config.onvif.autotracking.enabled
- and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[
+ and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
self.name
- ]
+ )
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
self.name
]
diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py
index f83a117510..6cb4f21b07 100644
--- a/frigate/comms/dispatcher.py
+++ b/frigate/comms/dispatcher.py
@@ -588,6 +588,10 @@ class Dispatcher:
self.ptz_metrics[camera_name].start_time.value = 0
ptz_autotracker_settings.enabled = False
+ self.config_updater.publish_update(
+ CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera_name),
+ ptz_autotracker_settings,
+ )
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py
index afa501904a..c0b9260873 100644
--- a/frigate/config/camera/updater.py
+++ b/frigate/config/camera/updater.py
@@ -14,6 +14,7 @@ class CameraConfigUpdateEnum(str, Enum):
add = "add" # for adding a camera
audio = "audio"
audio_transcription = "audio_transcription"
+ autotracking = "autotracking" # ptz autotracking only, without an onvif reinit
birdseye = "birdseye"
detect = "detect"
enabled = "enabled"
@@ -145,6 +146,8 @@ class CameraConfigUpdateSubscriber:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.onvif:
config.onvif = updated_config
+ elif update_type == CameraConfigUpdateEnum.autotracking:
+ config.onvif.autotracking = updated_config
elif update_type == CameraConfigUpdateEnum.timestamp_style:
config.timestamp_style = updated_config
elif update_type == CameraConfigUpdateEnum.zones:
diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py
index cd29dfe949..a38669cbf9 100644
--- a/frigate/output/birdseye.py
+++ b/frigate/output/birdseye.py
@@ -335,6 +335,7 @@ class BirdsEyeFrameManager:
self.camera_layout: list[Any] = []
self.active_cameras: set[str] = set()
+ self.layout_camera_order: list[str] = []
self.last_output_time = 0.0
def add_camera(self, cam: str) -> None:
@@ -372,6 +373,13 @@ class BirdsEyeFrameManager:
if cam in self.cameras:
del self.cameras[cam]
+ def sort_cameras(self, cameras: set[str]) -> list[str]:
+ """Sort cameras by birdseye order, falling back to name when tied."""
+ return sorted(
+ cameras,
+ key=lambda camera: (self.config.cameras[camera].birdseye.order, camera),
+ )
+
def clear_frame(self) -> None:
logger.debug("Clearing the birdseye frame")
self.frame[:] = self.blank_frame
@@ -482,6 +490,7 @@ class BirdsEyeFrameManager:
# if the layout needs to be cleared
self.camera_layout = []
self.active_cameras = set()
+ self.layout_camera_order = []
self.clear_frame()
frame_changed = True
layout_changed = True
@@ -500,21 +509,21 @@ class BirdsEyeFrameManager:
else:
reset_layout = True
+ sorted_active_cameras = self.sort_cameras(active_cameras)
+
+ if not reset_layout and sorted_active_cameras != self.layout_camera_order:
+ logger.debug("Birdseye camera order changed")
+ reset_layout = True
+
if reset_layout:
logger.debug("Resetting Birdseye layout...")
self.clear_frame()
self.active_cameras = active_cameras
+ self.layout_camera_order = sorted_active_cameras
layout_changed = True # Layout is changing due to reset
# 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,
- ),
- )
+ active_cameras_to_add = sorted_active_cameras
if len(active_cameras) == 1:
# show single camera as fullscreen
camera = active_cameras_to_add[0]
@@ -780,6 +789,7 @@ class BirdsEyeFrameManager:
frame_changed, layout_changed = False, False
self.active_cameras = set()
self.camera_layout = []
+ self.layout_camera_order = []
print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame
diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py
index 9a114c9524..e9bbda9889 100644
--- a/frigate/ptz/autotrack.py
+++ b/frigate/ptz/autotrack.py
@@ -20,6 +20,10 @@ from norfair.camera_motion import (
from frigate.camera import PTZMetrics
from frigate.comms.dispatcher import Dispatcher
from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
+from frigate.config.camera.updater import (
+ CameraConfigUpdateEnum,
+ CameraConfigUpdateSubscriber,
+)
from frigate.const import (
AUTOTRACKING_MAX_AREA_RATIO,
AUTOTRACKING_MAX_MOVE_METRICS,
@@ -194,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread):
def run(self):
while not self.stop_event.wait(1):
- for camera, camera_config in self.config.cameras.items():
+ self.ptz_autotracker.check_for_updates()
+
+ for camera, camera_config in list(self.config.cameras.items()):
if not camera_config.enabled:
continue
@@ -211,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread):
self.ptz_autotracker.tracked_object[camera] = None
self.ptz_autotracker.tracked_object_history[camera].clear()
+ self.ptz_autotracker.config_subscriber.stop()
logger.info("Exiting autotracker...")
@@ -244,6 +251,16 @@ class PtzAutoTracker:
self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {}
+ self.config_subscriber = CameraConfigUpdateSubscriber(
+ self.config,
+ self.config.cameras,
+ [
+ CameraConfigUpdateEnum.add,
+ CameraConfigUpdateEnum.autotracking,
+ CameraConfigUpdateEnum.onvif,
+ ],
+ )
+
# if cam is set to autotrack, onvif should be set up
for camera, camera_config in self.config.cameras.items():
if not camera_config.enabled:
@@ -260,6 +277,29 @@ class PtzAutoTracker:
# Wait for the coroutine to complete
future.result()
+ def check_for_updates(self) -> None:
+ """Apply camera config updates and mirror autotracking state to ptz metrics.
+
+ The camera processes read autotracker_enabled rather than the config, so it
+ has to follow every path that can change autotracking, not just the mqtt
+ toggle that writes it directly.
+ """
+ updates = self.config_subscriber.check_for_updates()
+
+ for cameras in updates.values():
+ for camera in cameras:
+ camera_config = self.config.cameras.get(camera)
+ metrics = self.ptz_metrics.get(camera)
+
+ # a camera added at runtime gets its metrics from the maintainer on
+ # another thread, which seeds them from this same config value
+ if camera_config is None or metrics is None:
+ continue
+
+ metrics.autotracker_enabled.value = (
+ camera_config.onvif.autotracking.enabled
+ )
+
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init")
@@ -1365,7 +1405,7 @@ class PtzAutoTracker:
camera_config = self.config.cameras[camera]
if camera_config.onvif.autotracking.enabled:
- if not self.autotracker_init[camera]:
+ if not self.autotracker_init.get(camera):
future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera), self.onvif.loop
)
@@ -1483,9 +1523,11 @@ class PtzAutoTracker:
}
async def camera_maintenance(self, camera):
- # bail and don't check anything if we're calibrating or tracking an object
+ # bail and don't check anything if we're not set up yet, calibrating, or
+ # tracking an object. a camera enabled at runtime has no autotracker_init
+ # entry until autotrack_object sets it up
if (
- not self.autotracker_init[camera]
+ not self.autotracker_init.get(camera)
or self.calibrating[camera]
or self.tracked_object[camera] is not None
):
diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py
index d29923e239..3fa786c883 100644
--- a/frigate/ptz/onvif.py
+++ b/frigate/ptz/onvif.py
@@ -344,16 +344,17 @@ class OnvifController:
autotracking_config.enabled_in_config and autotracking_config.enabled
)
- # autotracking-only: status request and service capabilities
- if autotracking_enabled:
- status_request = ptz.create_type("GetStatus")
- status_request.ProfileToken = profile.token
- self.cams[camera_name]["status_request"] = status_request
+ # these are local and cost nothing to build, and autotracking can be enabled
+ # after a camera is initialized, so always create them rather than baking the
+ # current config value into init state
+ status_request = ptz.create_type("GetStatus")
+ status_request.ProfileToken = profile.token
+ self.cams[camera_name]["status_request"] = status_request
- service_capabilities_request = ptz.create_type("GetServiceCapabilities")
- self.cams[camera_name]["service_capabilities_request"] = (
- service_capabilities_request
- )
+ service_capabilities_request = ptz.create_type("GetServiceCapabilities")
+ self.cams[camera_name]["service_capabilities_request"] = (
+ service_capabilities_request
+ )
# setup relative move request when FOV relative movement is supported
if (
diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py
index 33683f5c4b..bd70e37efe 100644
--- a/frigate/test/test_birdseye.py
+++ b/frigate/test/test_birdseye.py
@@ -1,8 +1,10 @@
"""Test camera user and password cleanup."""
+import multiprocessing as mp
import unittest
-from frigate.output.birdseye import get_canvas_shape
+from frigate.config import FrigateConfig
+from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
class TestBirdseye(unittest.TestCase):
@@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase):
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width # width will be the same
assert canvas_height != height
+
+
+class TestBirdseyeCameraOrder(unittest.TestCase):
+ """Test that birdseye reacts to camera order changes without a restart."""
+
+ def setUp(self):
+ config = {
+ "mqtt": {"enabled": False},
+ "birdseye": {"enabled": True, "mode": "continuous"},
+ "cameras": {
+ camera: {
+ "ffmpeg": {
+ "inputs": [
+ {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
+ ]
+ },
+ "detect": {"height": 1080, "width": 1920, "fps": 5},
+ }
+ for camera in ("back", "front", "side")
+ },
+ }
+ self.config = FrigateConfig(**config)
+ self.manager = BirdsEyeFrameManager(self.config, mp.Event())
+
+ # mark every camera as continuously active with no frame to draw, which
+ # exercises the layout without needing real yuv frames
+ for camera_data in self.manager.cameras.values():
+ camera_data["current_frame"] = None
+ camera_data["current_frame_time"] = 1.0
+ camera_data["last_active_frame"] = 1.0
+
+ def layout_order(self) -> list[str]:
+ """Return the cameras in the order the current layout renders them."""
+ return [position[0] for row in self.manager.camera_layout for position in row]
+
+ def test_layout_uses_configured_order(self):
+ """Test the layout is sorted by order, then by name when tied."""
+ self.config.cameras["side"].birdseye.order = 0
+ self.config.cameras["back"].birdseye.order = 10
+ self.config.cameras["front"].birdseye.order = 20
+
+ self.manager.update_frame()
+
+ assert self.layout_order() == ["side", "back", "front"]
+
+ def test_order_change_rebuilds_layout(self):
+ """Test a reorder relayouts even though the active cameras are unchanged."""
+ self.manager.update_frame()
+ assert self.layout_order() == ["back", "front", "side"]
+
+ # a stable active set means only an order change can reset the layout,
+ # which is what a settings reorder publishes to this process
+ self.config.cameras["side"].birdseye.order = -10
+
+ _, layout_changed = self.manager.update_frame()
+
+ assert layout_changed
+ assert self.layout_order() == ["side", "back", "front"]
+
+ def test_unchanged_order_keeps_layout(self):
+ """Test a repeat update with no order change doesn't reset the layout."""
+ self.manager.update_frame()
+
+ _, layout_changed = self.manager.update_frame()
+
+ assert not layout_changed
+ assert self.layout_order() == ["back", "front", "side"]
diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py
index e94625f645..c99414a419 100644
--- a/frigate/test/test_gpu_stats.py
+++ b/frigate/test/test_gpu_stats.py
@@ -21,8 +21,12 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
- def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names):
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_fdinfo(
+ self, drm_devices, read_fdinfo, monotonic, sleep, get_names
+ ):
# 1 second of wall clock between snapshots
+ drm_devices.return_value = {"0000:00:02.0": "i915"}
monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
@@ -96,13 +100,15 @@ class TestGpuStats(unittest.TestCase):
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
@patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
def test_intel_gpu_stats_xe_capacity(
- self, read_fdinfo, monotonic, sleep, get_names
+ self, drm_devices, read_fdinfo, monotonic, sleep, get_names
):
# Xe engines report cumulative cycles paired with total cycles, plus a
# per-class capacity. drm-cycles-* is summed across every instance of a
# class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be
# divided by capacity to land in 0-100%.
+ drm_devices.return_value = {"0000:03:00.0": "xe"}
monotonic.side_effect = [0.0, 1.0]
get_names.return_value = {"0000:03:00.0": "Intel Arc"}
@@ -150,7 +156,145 @@ class TestGpuStats(unittest.TestCase):
},
}
+ @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
+ @patch("frigate.util.services.time.sleep")
@patch("frigate.util.services._read_intel_drm_fdinfo")
- def test_intel_gpu_stats_no_clients(self, read_fdinfo):
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_no_clients_reports_idle(
+ self, drm_devices, read_fdinfo, sleep, get_names
+ ):
+ # The device exists but nothing holds it open, e.g. while camera
+ # processes are restarting. This is an idle state, not an error:
+ # returning None here would latch the hwaccel error cooldown and
+ # blank GPU stats for an hour over a momentary gap.
+ drm_devices.return_value = {"0000:00:02.0": "i915"}
read_fdinfo.return_value = {}
+ get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
+
+ assert get_intel_gpu_stats(None) == {
+ "0000:00:02.0": {
+ "name": "Intel Graphics",
+ "vendor": "intel",
+ "gpu": "0.0%",
+ "mem": "-%",
+ "compute": "0.0%",
+ "dec": "0.0%",
+ },
+ }
+ # Idle short-circuits before spending the sample window
+ sleep.assert_not_called()
+ read_fdinfo.assert_called_once()
+
+ @patch("frigate.util.services.time.sleep")
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_clients_without_engine_counters(
+ self, drm_devices, read_fdinfo, sleep
+ ):
+ # i915 publishes drm-driver/drm-pdev/drm-client-id but no drm-engine-*
+ # lines while GuC submission is active on kernels older than 6.5, so
+ # clients are found with nothing to sample. Reporting idle here would
+ # be a lie, and sampling a second time cannot help.
+ drm_devices.return_value = {"0000:00:02.0": "i915"}
+ read_fdinfo.return_value = {
+ ("0000:00:02.0", "48", "1109"): {
+ "driver": "i915",
+ "pid": "1109",
+ "engines": {},
+ },
+ ("0000:00:02.0", "51", "1258"): {
+ "driver": "i915",
+ "pid": "1258",
+ "engines": {},
+ },
+ }
+
assert get_intel_gpu_stats(None) is None
+ sleep.assert_not_called()
+ read_fdinfo.assert_called_once()
+
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo):
+ # Only a non-Intel GPU is visible in sysfs; /proc is never scanned
+ drm_devices.return_value = {"0000:01:00.0": "nvidia"}
+
+ assert get_intel_gpu_stats(None) is None
+ read_fdinfo.assert_not_called()
+
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ @patch("frigate.util.services._resolve_intel_gpu_pdev")
+ def test_intel_gpu_stats_unresolvable_device_hint(
+ self, resolve_pdev, drm_devices, read_fdinfo
+ ):
+ # A configured intel_gpu_device that cannot be resolved is a config
+ # error, not a reason to silently fall back to reporting all GPUs
+ resolve_pdev.return_value = None
+
+ assert get_intel_gpu_stats("/dev/dri/renderD999") is None
+ drm_devices.assert_not_called()
+ read_fdinfo.assert_not_called()
+
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ @patch("frigate.util.services._resolve_intel_gpu_pdev")
+ def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu(
+ self, resolve_pdev, drm_devices, read_fdinfo
+ ):
+ # card numbering can reorder across reboots on multi-GPU hosts, so a
+ # configured hint may point at another vendor's card; call it out
+ # instead of reporting nothing
+ resolve_pdev.return_value = "0000:01:00.0"
+ drm_devices.return_value = {
+ "0000:00:02.0": "i915",
+ "0000:01:00.0": "nvidia",
+ }
+
+ assert get_intel_gpu_stats("/dev/dri/card0") is None
+ read_fdinfo.assert_not_called()
+
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo):
+ # A scan failure (None) is a different condition than a scan that
+ # finds no clients ({}) and must not report idle
+ drm_devices.return_value = {"0000:00:02.0": "i915"}
+ read_fdinfo.return_value = None
+
+ assert get_intel_gpu_stats(None) is None
+
+ @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
+ @patch("frigate.util.services.time.sleep")
+ @patch("frigate.util.services.time.monotonic")
+ @patch("frigate.util.services._read_intel_drm_fdinfo")
+ @patch("frigate.util.services._enumerate_drm_devices")
+ def test_intel_gpu_stats_clients_lost_between_samples(
+ self, drm_devices, read_fdinfo, monotonic, sleep, get_names
+ ):
+ # Clients disappearing during the sample window is transient process
+ # churn, so report idle rather than latching an error
+ drm_devices.return_value = {"0000:00:02.0": "i915"}
+ monotonic.side_effect = [0.0, 1.0]
+ get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
+ read_fdinfo.side_effect = [
+ {
+ ("0000:00:02.0", "1", "100"): {
+ "driver": "i915",
+ "pid": "100",
+ "engines": {"video": (5_000_000_000, 0, 1)},
+ },
+ },
+ {},
+ ]
+
+ assert get_intel_gpu_stats(None) == {
+ "0000:00:02.0": {
+ "name": "Intel Graphics",
+ "vendor": "intel",
+ "gpu": "0.0%",
+ "mem": "-%",
+ "compute": "0.0%",
+ "dec": "0.0%",
+ },
+ }
diff --git a/frigate/test/test_ptz_autotrack.py b/frigate/test/test_ptz_autotrack.py
new file mode 100644
index 0000000000..1abcc780d0
--- /dev/null
+++ b/frigate/test/test_ptz_autotrack.py
@@ -0,0 +1,130 @@
+"""Tests for autotracker state that must survive runtime config changes.
+
+Regression coverage for a family of bugs where per-camera autotracker state was
+built once at startup and never revisited. A camera that is added or enabled
+after startup, or has autotracking enabled from the UI, would either raise a
+KeyError on the autotracker thread or silently keep the wrong state:
+
+- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
+ constructed, so runtime-enabled cameras raised KeyError on lookup.
+- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
+ updated it when autotracking was enabled through a config save, so it stayed
+ False and the tracker never built a motion estimator.
+"""
+
+import unittest
+from unittest.mock import MagicMock
+
+from frigate.camera import PTZMetrics
+from frigate.config import FrigateConfig
+from frigate.ptz.autotrack import PtzAutoTracker
+
+CAMERA = "ptz_cam"
+
+
+def _config(autotracking_enabled: bool) -> FrigateConfig:
+ return FrigateConfig(
+ **{
+ "mqtt": {"enabled": False},
+ "cameras": {
+ CAMERA: {
+ "ffmpeg": {
+ "inputs": [
+ {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
+ ]
+ },
+ "detect": {"width": 1920, "height": 1080},
+ "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
+ "onvif": {
+ "host": "10.0.0.1",
+ "autotracking": {
+ "enabled": autotracking_enabled,
+ "required_zones": ["zone"],
+ },
+ },
+ }
+ },
+ }
+ )
+
+
+def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
+ """Build a PtzAutoTracker without invoking __init__, which would try to set up
+ onvif over the network. Only the config/metrics state is relevant here."""
+ tracker = PtzAutoTracker.__new__(PtzAutoTracker)
+ tracker.config = _config(autotracking_enabled)
+ tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
+ tracker.onvif = MagicMock()
+ tracker.config_subscriber = MagicMock()
+ tracker.autotracker_init = {}
+ tracker.calibrating = {}
+ tracker.tracked_object = {}
+ return tracker
+
+
+class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
+ async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None:
+ # a camera enabled at runtime has no autotracker_init entry, which used to
+ # raise KeyError and kill the autotracker thread for every camera
+ tracker = _make_tracker()
+ self.assertNotIn(CAMERA, tracker.autotracker_init)
+
+ await tracker.camera_maintenance(CAMERA)
+
+ tracker.onvif.get_camera_status.assert_not_called()
+
+ async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None:
+ # autotracker_init is seeded False for enabled cameras before setup runs
+ tracker = _make_tracker()
+ tracker.autotracker_init[CAMERA] = False
+
+ await tracker.camera_maintenance(CAMERA)
+
+ tracker.onvif.get_camera_status.assert_not_called()
+
+
+class TestAutotrackerMetricSync(unittest.TestCase):
+ def test_metric_follows_config_when_enabled_by_update(self) -> None:
+ # autotracking enabled via a config save: the metric was seeded False when
+ # the camera was added and nothing else updates it
+ tracker = _make_tracker(autotracking_enabled=True)
+ metrics = tracker.ptz_metrics[CAMERA]
+ self.assertFalse(metrics.autotracker_enabled.value)
+
+ tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
+ tracker.check_for_updates()
+
+ self.assertTrue(metrics.autotracker_enabled.value)
+
+ def test_metric_follows_config_when_disabled_by_update(self) -> None:
+ tracker = _make_tracker(autotracking_enabled=False)
+ metrics = tracker.ptz_metrics[CAMERA]
+ metrics.autotracker_enabled.value = True
+
+ tracker.config_subscriber.check_for_updates.return_value = {
+ "autotracking": [CAMERA]
+ }
+ tracker.check_for_updates()
+
+ self.assertFalse(metrics.autotracker_enabled.value)
+
+ def test_metric_sync_skips_camera_without_metrics(self) -> None:
+ # `add` reaches the maintainer and the autotracker on separate threads with
+ # no ordering guarantee, so the metrics may not exist yet
+ tracker = _make_tracker()
+ tracker.ptz_metrics = {}
+ tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
+
+ tracker.check_for_updates()
+
+ def test_metric_sync_skips_unknown_camera(self) -> None:
+ tracker = _make_tracker()
+ tracker.config_subscriber.check_for_updates.return_value = {
+ "add": ["not_in_config"]
+ }
+
+ tracker.check_for_updates()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frigate/test/test_ptz_onvif.py b/frigate/test/test_ptz_onvif.py
new file mode 100644
index 0000000000..d2dd2d046e
--- /dev/null
+++ b/frigate/test/test_ptz_onvif.py
@@ -0,0 +1,147 @@
+"""Tests for ONVIF init state that must not depend on the autotracking config.
+
+Regression coverage for a camera that is initialized while autotracking is off and
+has it enabled later, which is the normal wizard flow: set the camera up first,
+configure autotracking afterwards. The autotracking-only request objects used to
+be created only when autotracking was enabled at init time, so the camera was left
+with init=True but no status_request. get_camera_status skips its re-init branch
+when init is True, so it went straight to the missing key and raised KeyError on
+the tracking thread.
+
+The request objects are built from the locally parsed WSDL and cost no network, so
+they are always created and init=True now implies they exist.
+"""
+
+import unittest
+from unittest.mock import AsyncMock, MagicMock
+
+from frigate.config import FrigateConfig
+from frigate.ptz.onvif import OnvifController
+
+CAMERA = "ptz_cam"
+
+
+def _config(autotracking_enabled: bool) -> FrigateConfig:
+ return FrigateConfig(
+ **{
+ "mqtt": {"enabled": False},
+ "cameras": {
+ CAMERA: {
+ "ffmpeg": {
+ "inputs": [
+ {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
+ ]
+ },
+ "detect": {"width": 1920, "height": 1080},
+ "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
+ "onvif": {
+ "host": "10.0.0.1",
+ "autotracking": {
+ "enabled": autotracking_enabled,
+ "required_zones": ["zone"],
+ },
+ },
+ }
+ },
+ }
+ )
+
+
+def _make_profile() -> MagicMock:
+ profile = MagicMock()
+ profile.token = "profile_1"
+ profile.Name = "MainStream"
+ profile.VideoEncoderConfiguration = MagicMock()
+ ptz_config = MagicMock()
+ ptz_config.token = "ptz_config_1"
+ ptz_config.DefaultContinuousPanTiltVelocitySpace = "space"
+ ptz_config.DefaultContinuousZoomVelocitySpace = "space"
+ profile.PTZConfiguration = ptz_config
+ return profile
+
+
+def _make_onvif_camera() -> MagicMock:
+ """A camera that supports PTZ but nothing optional, so init takes the simplest
+ path through the feature detection below."""
+ onvif = MagicMock()
+ onvif.update_xaddrs = AsyncMock()
+
+ video_source = MagicMock()
+ video_source.token = "video_source_1"
+
+ media = MagicMock()
+ media.GetProfiles = AsyncMock(return_value=[_make_profile()])
+ media.GetVideoSources = AsyncMock(return_value=[video_source])
+ onvif.create_media_service = AsyncMock(return_value=media)
+ onvif.get_definition = MagicMock(return_value={"ptz": "definition"})
+
+ ptz = MagicMock()
+ # create_type is a local WSDL lookup, so tag the result to assert on it later
+ ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name))
+ ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported"))
+ onvif.create_ptz_service = AsyncMock(return_value=ptz)
+ onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported"))
+ return onvif
+
+
+def _make_controller(autotracking_enabled: bool) -> OnvifController:
+ """Build a controller without invoking __init__, which would start an event loop
+ thread and reach out to the camera."""
+ config = _config(autotracking_enabled)
+ controller = OnvifController.__new__(OnvifController)
+ controller.config = config
+ controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
+ controller.failed_cams = {}
+ controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
+ controller.ptz_metrics = {CAMERA: MagicMock()}
+ return controller
+
+
+class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
+ async def test_status_request_created_when_autotracking_disabled(self) -> None:
+ # the wizard flow: onvif configured first, autotracking enabled later
+ controller = _make_controller(autotracking_enabled=False)
+
+ self.assertTrue(await controller._init_onvif(CAMERA))
+
+ cam = controller.cams[CAMERA]
+ self.assertTrue(cam["init"])
+ self.assertIn("status_request", cam)
+ self.assertIn("service_capabilities_request", cam)
+
+ async def test_status_request_created_when_autotracking_enabled(self) -> None:
+ controller = _make_controller(autotracking_enabled=True)
+
+ self.assertTrue(await controller._init_onvif(CAMERA))
+
+ cam = controller.cams[CAMERA]
+ self.assertIn("status_request", cam)
+ self.assertIn("service_capabilities_request", cam)
+
+ async def test_init_implies_status_request_exists(self) -> None:
+ # the invariant get_camera_status relies on: it skips re-init when init is
+ # True and then reads status_request without guarding
+ for autotracking_enabled in (True, False):
+ with self.subTest(autotracking_enabled=autotracking_enabled):
+ controller = _make_controller(autotracking_enabled)
+
+ await controller._init_onvif(CAMERA)
+
+ cam = controller.cams[CAMERA]
+ if cam["init"]:
+ self.assertEqual(cam["status_request"].request_type, "GetStatus")
+
+ async def test_requests_built_without_contacting_camera(self) -> None:
+ # create_type is a local WSDL lookup; cameras that do not implement
+ # GetServiceCapabilities must not be asked about it during init
+ controller = _make_controller(autotracking_enabled=False)
+
+ await controller._init_onvif(CAMERA)
+
+ ptz = controller.cams[CAMERA]["ptz"]
+ ptz.GetServiceCapabilities.assert_not_called()
+ ptz.GetStatus.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frigate/util/services.py b/frigate/util/services.py
index 62929d32df..38737907ef 100644
--- a/frigate/util/services.py
+++ b/frigate/util/services.py
@@ -285,6 +285,10 @@ _XE_ENGINE_KEYS = {
"vecs": "video-enhance",
"ccs": "compute",
}
+_INTEL_DRM_DRIVERS = ("i915", "xe")
+_PCI_ADDRESS_RE = re.compile(
+ r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$"
+)
def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
@@ -294,29 +298,70 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
if not device:
return None
- if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device):
+ if _PCI_ADDRESS_RE.match(device):
return device
name = os.path.basename(device.rstrip("/"))
try:
- return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
+ pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
except OSError:
return None
+ # realpath does not raise on a nonexistent node; it returns the input
+ # path unchanged, so validate the result actually looks like a PCI
+ # address before trusting it.
+ return pdev if _PCI_ADDRESS_RE.match(pdev) else None
-def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
+
+def _enumerate_drm_devices() -> dict[str, str]:
+ """Map each PCI-attached DRM device to its bound kernel driver.
+
+ Reads /sys/class/drm, which reflects every GPU on the host even when only
+ some render nodes are mapped into the container, so device presence can be
+ verified without /dev access. Returns {pdev: driver}, e.g.
+ {"0000:00:02.0": "i915"}.
+ """
+ devices: dict[str, str] = {}
+
+ try:
+ entries = os.listdir("/sys/class/drm")
+ except OSError:
+ return devices
+
+ for entry in entries:
+ device_dir = f"/sys/class/drm/{entry}/device"
+ pdev = os.path.basename(os.path.realpath(device_dir))
+
+ if not _PCI_ADDRESS_RE.match(pdev):
+ continue
+
+ try:
+ driver = os.path.basename(os.readlink(f"{device_dir}/driver"))
+ except OSError:
+ continue
+
+ devices[pdev] = driver
+
+ return devices
+
+
+def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None:
"""Snapshot DRM fdinfo for every Intel client visible in /proc.
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
seen via multiple file descriptors on a single process collapses to one
- entry.
+ entry. Clients whose fdinfo carries no engine counters are still included
+ with an empty "engines" dict so the caller can distinguish "clients exist
+ but the kernel publishes no busyness" from "no clients at all". Returns
+ None when /proc itself cannot be scanned, which is a different failure
+ than a scan that finds nothing.
"""
snapshot: dict = {}
try:
proc_entries = os.listdir("/proc")
except OSError:
- return snapshot
+ return None
for entry in proc_entries:
if not entry.isdigit():
@@ -400,41 +445,124 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
except (ValueError, IndexError):
continue
- if not engines:
- continue
-
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
return snapshot
+def _idle_intel_gpu_stats(
+ target_pdev: str | None, intel_pdevs: dict[str, str]
+) -> dict[str, dict[str, Any]]:
+ """Build a 0% reading for the configured (or every) Intel GPU.
+
+ Used when the device is confirmed present but no DRM client is currently
+ attached, e.g. while camera processes are restarting. That is an idle
+ state, not a collection failure, so it must produce a valid reading:
+ returning None would latch the hwaccel error cooldown and blank GPU stats
+ for an hour over a momentary gap.
+ """
+ from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
+
+ names = intel_gpu_name_resolver.get_names()
+ pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs)
+
+ return {
+ pdev: {
+ "name": names.get(pdev) or "Intel iGPU",
+ "vendor": "intel",
+ "gpu": "0.0%",
+ "mem": "-%",
+ "compute": "0.0%",
+ "dec": "0.0%",
+ }
+ for pdev in pdevs
+ }
+
+
def get_intel_gpu_stats(
intel_gpu_device: str | None,
) -> dict[str, dict[str, Any]] | None:
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
Each DRM client FD exposes monotonic per-engine busy counters via
- /proc//fdinfo/ (i915 since kernel 5.19, Xe since first release).
- We sample twice and divide busy-time deltas by wall-clock to derive
- utilization. Render/3D and Compute are pooled into "compute"; Video and
- VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
- to 100%).
+ /proc//fdinfo/. For i915 this requires kernel 6.5 or newer:
+ earlier kernels omit the per-engine counters whenever GuC submission is
+ active, which is the default on 12th gen and newer. Xe has exposed them
+ since its first release. We sample twice and divide busy-time deltas by
+ wall-clock to derive utilization. Render/3D and Compute are pooled into
+ "compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of
+ those pools (clamped to 100%).
The return value is keyed by the GPU's drm-pdev string so multiple Intel
GPUs in the same system are reported separately. Each entry carries a
"name" populated from OpenVINO (falling back to the pdev) so callers can
surface a real device name in the UI.
+
+ A device that exists but has no attached DRM clients reports an idle 0%
+ reading. None is returned only for durable failures (no Intel GPU, a bad
+ intel_gpu_device config, unreadable /proc, or a kernel that publishes no
+ counters), each of which logs a distinct warning, and the caller latches
+ it against retries for an hour.
"""
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
+ if intel_gpu_device and not target_pdev:
+ logger.warning(
+ "Unable to collect Intel GPU stats: configured intel_gpu_device %s "
+ "does not exist or could not be resolved to a PCI device",
+ intel_gpu_device,
+ )
+ return None
+
+ drm_devices = _enumerate_drm_devices()
+ intel_pdevs = {
+ pdev: driver
+ for pdev, driver in drm_devices.items()
+ if driver in _INTEL_DRM_DRIVERS
+ }
+
+ if not intel_pdevs:
+ logger.warning(
+ "Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in "
+ "/sys/class/drm. Check that the driver is loaded on the host"
+ )
+ return None
+
+ if target_pdev and target_pdev not in intel_pdevs:
+ logger.warning(
+ "Unable to collect Intel GPU stats: configured intel_gpu_device %s "
+ "resolved to %s (driver: %s), which is not an Intel GPU",
+ intel_gpu_device,
+ target_pdev,
+ drm_devices.get(target_pdev, "unknown"),
+ )
+ return None
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
+ if snapshot_a is None:
+ logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
+ return None
+
if not snapshot_a:
+ # No process currently holds the GPU open, e.g. while camera processes
+ # are restarting. The device is confirmed present, so report idle
+ # rather than an error; the next stats cycle re-samples normally.
+ logger.debug("No active DRM clients for Intel GPU, reporting idle")
+ return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
+
+ if not any(client["engines"] for client in snapshot_a.values()):
+ # Clients exist but the kernel published no busyness for them, so
+ # there is nothing to sample and a second snapshot would not help.
+ # i915 suppresses per-client engine counters while GuC submission is
+ # active on kernels older than 6.5 (kernel commit 1324680a80eb lifted
+ # this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen
+ # and newer.
logger.warning(
- "Unable to collect Intel GPU stats: no DRM fdinfo entries found"
- "%s. Check that /proc is readable and the i915/xe driver is loaded",
- f" for pdev {target_pdev}" if target_pdev else "",
+ "Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
+ "no per-engine counters. Kernel 6.5 or newer is required.",
+ len(snapshot_a),
+ "/".join(sorted({client["driver"] for client in snapshot_a.values()})),
)
return None
@@ -443,12 +571,18 @@ def get_intel_gpu_stats(
elapsed_ns = (time.monotonic() - start) * 1e9
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
- if not snapshot_b or elapsed_ns <= 0:
- logger.warning(
- "Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
- )
+ if snapshot_b is None:
+ logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
return None
+ if not snapshot_b or elapsed_ns <= 0:
+ # Every client disappeared during the sample window; transient by
+ # definition, so report idle instead of latching an error.
+ logger.debug(
+ "No DRM clients persisted across Intel GPU samples, reporting idle"
+ )
+ return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
+
def _new_engine_pct() -> dict[str, float]:
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
@@ -460,6 +594,11 @@ def get_intel_gpu_stats(
if not data_a or data_a["driver"] != data_b["driver"]:
continue
+ # Skip before the setdefault below so a counter-less client cannot
+ # register its pdev on its own.
+ if not data_b["engines"]:
+ continue
+
pdev = key[0]
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
@@ -491,11 +630,12 @@ def get_intel_gpu_stats(
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
if not per_pdev_engine_pct:
- logger.warning(
- "Unable to collect Intel GPU stats: no per-engine counters available "
- "(i915 requires kernel >= 5.19)"
+ # Clients were seen in both snapshots but none persisted as the same
+ # (pdev, client-id, pid); process churn, so report idle.
+ logger.debug(
+ "No DRM clients persisted across Intel GPU samples, reporting idle"
)
- return None
+ return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
names = intel_gpu_name_resolver.get_names()
results: dict[str, dict[str, Any]] = {}
diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json
index 9f387a7f30..84014aaffe 100644
--- a/web/public/locales/en/views/system.json
+++ b/web/public/locales/en/views/system.json
@@ -107,12 +107,7 @@
},
"npuUsage": "NPU Usage",
"npuMemory": "NPU Memory",
- "npuTemperature": "NPU Temperature",
- "intelGpuWarning": {
- "title": "Intel GPU Stats Warning",
- "message": "GPU stats unavailable",
- "description": "This is a known bug in Intel's GPU stats reporting tools (intel_gpu_top) where it will break and repeatedly return a GPU usage of 0% even in cases where hardware acceleration and object detection are correctly running on the (i)GPU. This is not a Frigate bug. You can restart the host to temporarily fix the issue and confirm that the GPU is working correctly. This does not affect performance."
- }
+ "npuTemperature": "NPU Temperature"
},
"otherProcesses": {
"title": "Other Processes",
diff --git a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
index b481e9a86f..f448310230 100644
--- a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
+++ b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
@@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({
try {
await axios.put("config/set", {
requires_restart: 0,
+ update_topic: "config/cameras/*/birdseye",
config_data: { cameras: cameraUpdates },
});
await updateConfig();
diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx
index a4775ae21b..73f64527c3 100644
--- a/web/src/components/icons/IconPicker.tsx
+++ b/web/src/components/icons/IconPicker.tsx
@@ -153,11 +153,15 @@ export default function IconPicker({
}
type IconRendererProps = {
- icon: IconType;
+ icon: IconType | undefined;
size?: number;
className?: string;
};
export function IconRenderer({ icon, size, className }: IconRendererProps) {
+ if (!icon) {
+ return null;
+ }
+
return <>{React.createElement(icon, { size, className })}>;
}
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx
index d4f5b14a7b..0b0cb84c13 100644
--- a/web/src/components/overlay/ExportDialog.tsx
+++ b/web/src/components/overlay/ExportDialog.tsx
@@ -65,6 +65,7 @@ import { Textarea } from "../ui/textarea";
import { useNavigate } from "react-router-dom";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { isReplayCamera } from "@/utils/cameraUtil";
+import { isValidIconName } from "@/utils/iconUtil";
const EXPORT_OPTIONS = [
"1",
@@ -1066,7 +1067,11 @@ export function ExportContent({
}
>
{group.name}
diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx
index 6d032de840..4cba965298 100644
--- a/web/src/views/system/GeneralMetrics.tsx
+++ b/web/src/views/system/GeneralMetrics.tsx
@@ -470,50 +470,6 @@ export default function GeneralMetrics({
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
}, [statsHistory]);
- // Check if Intel GPU has all 0% usage values (known bug)
- const showIntelGpuWarning = useMemo(() => {
- if (!statsHistory || statsHistory.length < 3) {
- return false;
- }
-
- const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some(
- (stats) => stats.vendor === "intel",
- );
-
- if (!hasIntelGpu) {
- return false;
- }
-
- // Check if all GPU usage values are 0% across all stats
- let allZero = true;
- let hasDataPoints = false;
-
- for (const stats of statsHistory) {
- if (!stats) {
- continue;
- }
-
- Object.values(stats.gpu_usages || {}).forEach((gpuStats) => {
- if (gpuStats.vendor !== "intel") {
- return;
- }
- if (gpuStats.gpu) {
- hasDataPoints = true;
- const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
- if (!isNaN(gpuValue) && gpuValue > 0) {
- allZero = false;
- }
- }
- });
-
- if (!allZero) {
- break;
- }
- }
-
- return hasDataPoints && allZero;
- }, [statsHistory]);
-
// npu stats
const npuSeries = useMemo(() => {
@@ -819,46 +775,8 @@ export default function GeneralMetrics({
<>
{statsHistory.length != 0 ? (