Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
29bb7ddc2c
Merge 37edbae696 into 50f17e6852 2026-05-29 10:10:08 +12:00
Josh Hawkins
50f17e6852
Add live streams widget (#23330)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add live streams widget

* i18n

* docs
2026-05-27 14:35:07 -05:00
Josh Hawkins
e9ef4f978a
Restore runtime state on startup (#23326)
* add class

* restore runtime state in dispatcher

* restore on startup with special case for profile

* add tests

* update docs

* mypy
2026-05-27 12:03:09 -06:00
Josh Hawkins
2858662be9
Miscellaneous fixes (#23317)
* resolve global record.export.hwaccel_args to fix phantom camera override

* auto-stop debug replay sessions after 12 hours

* docs tweaks

* add more tips to object classification docs

* tweak language

* Store hwaccel errors with timeout so it can retry

* Add error logs for Intel GPU stats

* add area

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-27 09:19:11 -06:00
Ban
88f944fe81
feat: add Traditional Chinese (zh-Hant) language option (#23322)
The zh-Hant translations are synced from Weblate (98% complete) but the
locale was never registered in the language selector, so users could not
select it. Register zh-Hant in supportedLanguageKeys, add its display
label, and map it to the zh-TW date-fns locale.
2026-05-27 08:04:41 -05:00
dependabot[bot]
37edbae696
Update faster-whisper requirement in /docker/main
Updates the requirements on [faster-whisper](https://github.com/SYSTRAN/faster-whisper) to permit the latest version.
- [Release notes](https://github.com/SYSTRAN/faster-whisper/releases)
- [Commits](https://github.com/SYSTRAN/faster-whisper/compare/v1.1.0...v1.2.1)

---
updated-dependencies:
- dependency-name: faster-whisper
  dependency-version: 1.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 13:19:20 +00:00
30 changed files with 1188 additions and 29 deletions

View File

@ -76,7 +76,7 @@ tflite_runtime @ https://github.com/frigate-nvr/TFlite-builds/releases/download/
tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.17.1/tflite_runtime-2.17.1-cp311-cp311-linux_aarch64.whl; platform_machine == 'aarch64'
# audio transcription
sherpa-onnx==1.12.*
faster-whisper==1.1.*
faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
# DeGirum detector

View File

@ -149,9 +149,16 @@ For more detail, see [Frigate Tip: Best Practices for Training Face and Custom C
- **The wizard is just the starting point**: You don't need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
- **Preprocessing**: Ensure examples reflect object crops similar to Frigate's boxes; keep the subject centered.
- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels.
- **Crop size**: Aim for crops of at least 100×100 pixels (a 10,000 pixel area). Crops smaller than ~80×80 get stretched 3-7× by the model's 224×224 input resize and tend to collapse into a generic "blob" region of feature space where identity becomes unreliable. If most of your detections are small because the camera is far from the subject, consider repositioning the camera for closer crops.
- **Class balance**: Aim to keep your largest class within ~3× the count of your smallest. Beyond that, the model becomes biased toward the dominant class and tends to default borderline predictions to it (the "everything looks like Buddy" failure mode).
- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation.
:::tip `none` works differently from named classes
Named classes work best with visually uniform examples — every Buddy photo should look like Buddy. The `none` class needs the opposite: visual diversity across sizes, framings, and qualities, because at inference it has to absorb everything that isn't one of your named classes. Don't apply the same "only keep large, well-framed images" rule to `none` that you would to a named class. Mix in small crops, partial views, and false positives deliberately - otherwise the model has no signal for "small/ambiguous thing = not one of my known classes" and will force those crops into a named class by default.
:::
## Debugging Classification Models
To troubleshoot issues with object classification models, enable debug logging to see detailed information about classification attempts, scores, and consensus calculations.

View File

@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" />, then select your camera.
- Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`).
1. Navigate to <NavPath path="Settings > Camera configuration > Live playback" /> and select your camera.
2. Under **Live stream names**, click **Add stream** to add a new entry.
3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`).
4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value.
5. Repeat for each additional stream you want to expose (e.g., `Sub Stream``test_cam_sub`).
6. Use the trash icon on a row to remove a stream, then **Save** the section.
:::tip
Configure your go2rtc streams first under <NavPath path="Settings > System > go2rtc streams" /> so the dropdown is populated with valid options.
:::
</TabItem>
<TabItem value="yaml">
@ -262,7 +272,7 @@ cameras:
Each camera has three possible states, surfaced as a status selector in **Settings → Global configuration → Camera management**:
- **On** — streams are processed normally. Object detection, recording, and Live view are active.
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. This state does **not** persist across Frigate restarts; the camera returns to On after a restart.
- **Off** — Frigate's ffmpeg processes are paused. Recording stops, object detection is paused, and the Live dashboard displays a blank image with a "Camera is off" message. The camera is still visible in the Live dashboard and its past review items, tracked objects, and historical footage remain accessible via the UI. The Off state persists across Frigate restarts via a `.runtime_state.json` file alongside `config.yml` (see [Runtime toggle persistence](#runtime-toggle-persistence)).
- **Disabled** — the change is saved to your configuration file (`enabled: False`). The camera stops immediately, Frigate stops ffmpeg processes, and all live and historical UI elements for the camera are no longer visible but remains retained on disk. The camera is still listed in **Settings → Global configuration → Camera management** so it can be re-enabled. **A restart of Frigate is required to bring a disabled camera back to On.**
#### Turning a camera on or off
@ -290,6 +300,15 @@ For both Off and Disabled cameras, go2rtc remains active but does not use system
If you want a camera's historical data (review items, tracked objects, footage) to stay accessible in the UI while you stop processing, set the camera to **Off**. If you want the camera fully removed from the Live dashboard, review filters, and other UI surfaces, set it to **Disabled**. The Disabled state still keeps the camera in Camera management so it can be re-enabled later; if you want to remove all traces of a camera including its configuration, delete it via Camera management instead.
#### Runtime toggle persistence
The Live view toggles for **camera on/off**, **detect**, **recordings**, **snapshots**, and **audio detection** — along with the equivalent MQTT `/set` topics — write the new state to `.runtime_state.json` next to your `config.yml`. The file is replayed on Frigate startup so your last-known toggle states survive a restart. Two interactions worth knowing:
- **Settings UI saves win.** When you save a field through **Settings → Global configuration**, the matching entry is cleared from `.runtime_state.json` so the new value in your config file is the durable source.
- **Switching profiles clears all runtime overrides.** Activating or deactivating a [profile](/configuration/profiles) is treated as a deliberate state change, so the file is wiped to avoid stale overrides replaying on top of the new profile.
If you hand-edit `config.yml` while runtime overrides exist, the overrides will still replay on restart. Delete `.runtime_state.json` to reset to the YAML-defined defaults.
### Live player error messages
When your browser runs into problems playing back your camera streams, it will log short error messages to the browser console. They indicate playback, codec, or network issues on the client/browser side, not something server side with Frigate itself. Below are the common messages you may see and simple actions you can take to try to resolve them.

View File

@ -130,6 +130,8 @@ Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integratio
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
Activating or deactivating a profile clears any [runtime toggle overrides](/configuration/live#runtime-toggle-persistence) so the profile's settings aren't silently undone by a stale toggle from before the switch.
## Example: Home / Away Setup
A common use case is having different detection and notification settings based on whether you are home or away. This example below is for a system with two cameras, `front_door` and `indoor_cam`.

View File

@ -139,7 +139,7 @@ The Review page also can show periods of motion that didn't produce a tracked ob
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
On the <NavPath path="Review > Motion" /> page, click the kebab menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
The pane provides a few controls:
@ -153,7 +153,7 @@ Clicking a preview clip seeks the recording player to that timestamp so you can
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
To start a search, click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
2. Draw a polygon on the camera frame to define the region of interest.

View File

@ -368,7 +368,7 @@ The published value is the detected state class name (e.g., `open`, `closed`, `o
### `frigate/<camera_name>/enabled/set`
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is **not** persisted across Frigate restarts — the camera returns to the configured state on restart. To permanently disable a camera, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
Topic to turn Frigate's processing of a camera on or off at runtime. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)). To permanently change the configured value, use **Settings → Global configuration → Camera management** in the Frigate UI. See [Camera state](/configuration/live#camera-state) for the difference between turning a camera off and disabling it.
### `frigate/<camera_name>/enabled/state`
@ -376,7 +376,7 @@ Topic with current runtime state of processing for a camera. Published values ar
### `frigate/<camera_name>/detect/set`
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/detect/state`
@ -384,7 +384,7 @@ Topic with current state of object detection for a camera. Published values are
### `frigate/<camera_name>/audio/set`
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn audio detection for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/audio/state`
@ -392,7 +392,7 @@ Topic with current state of audio detection for a camera. Published values are `
### `frigate/<camera_name>/recordings/set`
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn recordings for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/recordings/state`
@ -400,7 +400,7 @@ Topic with current state of recordings for a camera. Published values are `ON` a
### `frigate/<camera_name>/snapshots/set`
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`. The change is persisted across Frigate restarts (see [Runtime toggle persistence](/configuration/live#runtime-toggle-persistence)).
### `frigate/<camera_name>/snapshots/state`

View File

@ -3,6 +3,8 @@ id: dummy-camera
title: Analyzing Object Detection
---
import NavPath from "@site/src/components/NavPath";
Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios.
## Reviewing Detections in the UI
@ -51,12 +53,25 @@ Only one replay session can be active at a time. If a session is already running
:::
### Starting Debug Replay
Debug Replay can be started from several places in the UI. The starting point determines the time range that gets replayed.
- **History — Actions menu.** Navigate to <NavPath path="History > {camera}" />, open the **Actions** menu in the toolbar, and choose **Debug Replay**. From here you can pick a preset (**Last 1 Minute**, **Last 5 Minutes**), select a range directly on the timeline with **From Timeline**, or enter exact start and end times with **Custom**. This is the most flexible option and the best choice when you want to add padding around a detection. On mobile, the same options appear in the Actions drawer.
- **History — Detail Stream event menu.** While viewing a review item in the Detail Stream, open the menu on a tracked object's event card and choose **Debug Replay**. The replay range is set automatically to that object's start and end times.
- **Explore — search result menu.** From an Explore card, open the kebab menu and choose **Debug Replay**. The range is taken from the tracked object's lifecycle.
- **Explore — Tracking Details Actions menu.** Open a tracked object's **Tracking Details** dialog, then choose **Debug Replay** from the Actions menu. Same automatic range as the search result menu.
- **Exports — export card menu.** From <NavPath path="Exports" />, open the menu on an export and choose **Debug Replay** to loop the exported clip through the detection pipeline for the camera it was exported from.
The Detail Stream, Explore, and Exports entry points use the underlying recording or export's bounds with a small amount of padding. This can be convenient for quick checks, but if a detection is short or you want extra "settle" time for motion and the detector, start the replay from the History Actions menu instead and widen the range manually.
### Variables to consider
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
- In cases where a detection is short and a replay may only be a small number of frames, it is recommended to manually add some padding before and after the detection so that the motion and object detectors have time to settle into the scene. Rather than starting Debug Replay from Explore, navigate to History for your camera, choose Debug Replay from the Actions menu, and click the "From Timeline" or "Custom" option.
- The replay camera inherits the source camera's zones. Any automations that trigger on those zone names will fire for the replay camera as well. This can be helpful when debugging zone behavior, but may be unexpected. You can add a condition on the source camera's name in your automation if you want to exclude replay triggers.
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.

View File

@ -908,6 +908,11 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=500,
)
# drop runtime overrides for any fields the user just rewrote in
# yaml so a stale override doesn't silently win after restart
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config

View File

@ -1,3 +1,4 @@
import asyncio
import logging
import re
from typing import Optional
@ -36,7 +37,7 @@ from frigate.comms.event_metadata_updater import (
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
from frigate.ptz.onvif import OnvifController
@ -116,6 +117,11 @@ def create_fastapi_app(
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None:

View File

@ -348,7 +348,11 @@ class FrigateApp:
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
self.profile_manager.activate_profile(persisted)
# don't clear runtime overrides here, restore_runtime_state() later
# in startup replays it on top of the activated profile
self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False
)
def start_detectors(self) -> None:
for name in self.config.cameras.keys():
@ -612,6 +616,9 @@ class FrigateApp:
self.start_record_cleanup()
self.start_watchdog()
# restore persisted runtime overrides on top of config
self.dispatcher.restore_runtime_state()
self.init_auth()
try:

View File

@ -3,11 +3,13 @@
import datetime
import json
import logging
from collections.abc import Iterable
from typing import Any, Callable, Optional, cast
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.config.camera.updater import (
@ -67,6 +69,7 @@ class Dispatcher:
self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle"
self._runtime_state = RuntimeStatePersistence()
self._camera_settings_handlers: dict[str, Callable] = {
"audio": self._on_audio_command,
"audio_transcription": self._on_audio_transcription_command,
@ -397,6 +400,60 @@ class Dispatcher:
for comm in self.comms:
comm.stop()
def restore_runtime_state(self) -> None:
"""Replay persisted runtime overrides through the camera settings handlers.
Called once after Frigate startup completes so processing threads can
receive the resulting ``config_updater`` broadcasts. Unknown cameras
and topics are skipped; handler exceptions are logged and replay
continues for remaining entries.
"""
state = self._runtime_state.load()
for camera_name, features in state.items():
if camera_name not in self.config.cameras:
continue
for topic, value in features.items():
handler = self._camera_settings_handlers.get(topic)
if handler is None:
continue
payload = "ON" if value else "OFF"
try:
handler(camera_name, payload)
except Exception:
logger.exception(
"Failed to restore runtime state %s.%s=%s",
camera_name,
topic,
payload,
)
continue
logger.info(
"Restored runtime state: %s.%s=%s",
camera_name,
topic,
payload,
)
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Clear stored runtime overrides for YAML keys that were just rewritten.
Called by ``/api/config/set`` after a successful YAML save so an
explicit settings-UI save isn't silently overridden by an older
runtime toggle on the next restart.
"""
self._runtime_state.clear_for_yaml_keys(dotted_keys)
def clear_runtime_state(self) -> None:
"""Wipe every stored runtime override.
Called when a profile is activated or deactivated. A profile switch
changes the layer below the runtime overrides, so the stored
"steady state" is no longer valid and must be reset; otherwise a
subsequent restart would replay stale overrides on top of the new
profile-derived in-memory state.
"""
self._runtime_state.clear_all()
def _on_detect_command(self, camera_name: str, payload: str) -> None:
"""Callback for detect topic."""
detect_settings = self.config.cameras[camera_name].detect
@ -428,6 +485,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.detect, camera_name),
detect_settings,
)
self._runtime_state.set(camera_name, "detect", detect_settings.enabled)
self.publish(f"{camera_name}/detect/state", payload, retain=True)
def _on_enabled_command(self, camera_name: str, payload: str) -> None:
@ -452,6 +510,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.enabled, camera_name),
camera_settings.enabled,
)
self._runtime_state.set(camera_name, "enabled", camera_settings.enabled)
self.publish(f"{camera_name}/enabled/state", payload, retain=True)
def _on_motion_command(self, camera_name: str, payload: str) -> None:
@ -614,6 +673,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.audio, camera_name),
audio_settings,
)
self._runtime_state.set(camera_name, "audio", audio_settings.enabled)
self.publish(f"{camera_name}/audio/state", payload, retain=True)
def _on_audio_transcription_command(self, camera_name: str, payload: str) -> None:
@ -670,6 +730,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.record, camera_name),
record_settings,
)
self._runtime_state.set(camera_name, "recordings", record_settings.enabled)
self.publish(f"{camera_name}/recordings/state", payload, retain=True)
def _on_snapshots_command(self, camera_name: str, payload: str) -> None:
@ -689,6 +750,7 @@ class Dispatcher:
CameraConfigUpdateTopic(CameraConfigUpdateEnum.snapshots, camera_name),
snapshots_settings,
)
self._runtime_state.set(camera_name, "snapshots", snapshots_settings.enabled)
self.publish(f"{camera_name}/snapshots/state", payload, retain=True)
def _on_ptz_command(self, camera_name: str, payload: str | bytes) -> None:

View File

@ -0,0 +1,163 @@
"""Persistence layer for dispatcher runtime state overrides."""
import json
import logging
import os
from collections.abc import Iterable
from typing import Any
from filelock import FileLock, Timeout
from frigate.util.config import find_config_file
logger = logging.getLogger(__name__)
class RuntimeStatePersistence:
"""Persist last-known runtime states for dispatcher toggles.
Stores boolean overrides applied to camera-level toggles by the dispatcher.
Overrides are replayed at startup on top of the YAML-derived in-memory
config, so changes made via MQTT or the live-view UI survive a restart.
"""
# Maps dispatcher topic name -> YAML key suffix under cameras.<cam>
TRACKED_TOPICS: dict[str, str] = {
"enabled": "enabled",
"detect": "detect.enabled",
"snapshots": "snapshots.enabled",
"recordings": "record.enabled",
"audio": "audio.enabled",
}
_SUFFIX_TO_TOPIC: dict[str, str] = {v: k for k, v in TRACKED_TOPICS.items()}
def __init__(self) -> None:
self._path = os.path.join(
os.path.dirname(find_config_file()), ".runtime_state.json"
)
self._lock_path = f"{self._path}.lock"
self._lock_timeout = 5
def load(self) -> dict[str, dict[str, bool]]:
"""Return {camera: {topic: bool}} or {} if missing/corrupt."""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
except Timeout:
logger.error("Timed out acquiring runtime state lock for load")
return {}
cameras = data.get("cameras", {})
if not isinstance(cameras, dict):
return {}
# Filter out malformed camera entries so callers can trust the shape.
return {
name: features
for name, features in cameras.items()
if isinstance(features, dict)
}
def set(self, camera: str, topic: str, value: bool) -> None:
"""Persist a single (camera, topic, value). No-op if topic untracked."""
if topic not in self.TRACKED_TOPICS:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.setdefault("cameras", {})
if not isinstance(cameras, dict):
cameras = {}
data["cameras"] = cameras
cam = cameras.setdefault(camera, {})
if not isinstance(cam, dict):
cam = {}
cameras[camera] = cam
cam[topic] = bool(value)
self._write_locked(data)
except Timeout:
logger.error("Timed out persisting runtime state for %s/%s", camera, topic)
except OSError:
logger.exception("Failed to persist runtime state for %s/%s", camera, topic)
def clear_all(self) -> None:
"""Wipe every stored runtime override.
Called when the "layer below" changes in a way that invalidates all
runtime overrides for the current session (currently: profile
activation or deactivation).
"""
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
if not os.path.exists(self._path):
return
self._write_locked({"cameras": {}})
except Timeout:
logger.error("Timed out clearing runtime state")
except OSError:
logger.exception("Failed to clear runtime state")
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
"""Remove stored entries whose YAML key was just rewritten.
Each dotted key must be of the form ``cameras.<camera>.<suffix>``.
Keys that don't match a tracked topic are ignored.
"""
to_remove: list[tuple[str, str]] = []
for key in dotted_keys:
parts = key.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
camera = parts[1]
suffix = ".".join(parts[2:])
topic = self._SUFFIX_TO_TOPIC.get(suffix)
if topic is not None:
to_remove.append((camera, topic))
if not to_remove:
return
try:
with FileLock(self._lock_path, timeout=self._lock_timeout):
data = self._read_locked()
cameras = data.get("cameras")
if not isinstance(cameras, dict):
return
changed = False
for camera, topic in to_remove:
cam = cameras.get(camera)
if isinstance(cam, dict) and topic in cam:
del cam[topic]
changed = True
if not cam:
del cameras[camera]
if changed:
self._write_locked(data)
except Timeout:
logger.error("Timed out clearing runtime state for YAML keys")
except OSError:
logger.exception("Failed to clear runtime state for YAML keys")
def _read_locked(self) -> dict[str, Any]:
"""Read the JSON file while the FileLock is held.
Returns ``{}`` on a missing or corrupt file so the caller can write a
fresh structure on the next mutation.
"""
if not os.path.exists(self._path):
return {}
try:
with open(self._path, "r") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
logger.exception(
"Failed to read runtime state file %s; starting fresh", self._path
)
return {}
return data if isinstance(data, dict) else {}
def _write_locked(self, data: dict[str, Any]) -> None:
"""Atomically write the JSON file while the FileLock is held."""
tmp_path = f"{self._path}.tmp"
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
os.replace(tmp_path, self._path)

View File

@ -680,6 +680,13 @@ class FrigateConfig(FrigateBaseModel):
if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Resolve global export hwaccel_args so it matches the per-camera
# resolution below. Without this, every camera reads as overriding
# record.export.hwaccel_args because the global stays "auto" while
# the camera value gets resolved to the actual args list.
if self.record.export.hwaccel_args == "auto":
self.record.export.hwaccel_args = self.ffmpeg.hwaccel_args
# Populate global audio filters from listen. Existing user-defined
# entries for labels not in listen are preserved but unused at runtime.
if self.audio.filters is None:

View File

@ -124,11 +124,24 @@ class ProfileManager:
self.config.active_profile = None
self._persist_active_profile(None)
def activate_profile(self, profile_name: Optional[str]) -> Optional[str]:
# drop all runtime overrides so they don't replay stale values on restart
if self.dispatcher is not None:
self.dispatcher.clear_runtime_state()
def activate_profile(
self,
profile_name: Optional[str],
clear_runtime_overrides: bool = True,
) -> Optional[str]:
"""Activate a profile by name, or deactivate if None.
Args:
profile_name: Profile name to activate, or None to deactivate.
clear_runtime_overrides: When True (the default, for user-initiated
activations) drop the dispatcher's runtime override file because
the layer below changed. Startup callers that are replaying a
persisted profile pass False so the runtime state stays
available for the subsequent replay step.
Returns:
None on success, or an error message string on failure.
@ -156,6 +169,11 @@ class ProfileManager:
self.config.active_profile = profile_name
self._persist_active_profile(profile_name)
# a profile switch invalidates the steady-state runtime overrides
if clear_runtime_overrides and self.dispatcher is not None:
self.dispatcher.clear_runtime_state()
logger.info(
"Profile %s",
f"'{profile_name}' activated" if profile_name else "deactivated",

View File

@ -5,6 +5,7 @@ frigate.jobs.debug_replay. This module owns only session presence
(active), session metadata, and post-session cleanup.
"""
import asyncio
import logging
import os
import shutil
@ -40,6 +41,9 @@ from frigate.util.config import find_config_file
logger = logging.getLogger(__name__)
MAX_SESSION_DURATION_SECONDS = 12 * 60 * 60
AUTO_STOP_CHECK_INTERVAL_SECONDS = 60
class DebugReplayManager:
"""Owns the lifecycle pointers for a single debug replay session.
@ -58,6 +62,7 @@ class DebugReplayManager:
self.clip_path: str | None = None
self.start_ts: float | None = None
self.end_ts: float | None = None
self.session_started_at: float | None = None
self._job_state_publisher = JobStatePublisher()
@property
@ -83,6 +88,7 @@ class DebugReplayManager:
self.start_ts = start_ts
self.end_ts = end_ts
self.clip_path = None
self.session_started_at = time.time()
def mark_session_ready(self, clip_path: str) -> None:
"""Record the on-disk clip path after the camera has been published."""
@ -104,6 +110,7 @@ class DebugReplayManager:
self.clip_path = None
self.start_ts = None
self.end_ts = None
self.session_started_at = None
def publish_camera(
self,
@ -351,3 +358,41 @@ def cleanup_replay_cameras() -> None:
shutil.rmtree(REPLAY_DIR)
except Exception as e:
logger.error("Failed to remove replay cache directory: %s", e)
async def debug_replay_auto_stop_watchdog(
manager: DebugReplayManager,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> None:
"""Auto-stop debug replay sessions that exceed MAX_SESSION_DURATION_SECONDS.
Backstop against a session left running for days. The cap is intentionally
generous so realistic tuning and overnight soak workflows aren't disrupted.
"""
while True:
try:
await asyncio.sleep(AUTO_STOP_CHECK_INTERVAL_SECONDS)
started_at = manager.session_started_at
if not manager.active or started_at is None:
continue
if time.time() - started_at < MAX_SESSION_DURATION_SECONDS:
continue
replay_name = manager.replay_camera_name
await asyncio.to_thread(
manager.stop,
frigate_config=frigate_config,
config_publisher=config_publisher,
)
logger.info(
"Debug replay auto-stopped after exceeding max session duration of %d hours: %s",
MAX_SESSION_DURATION_SECONDS // 3600,
replay_name,
)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Error in debug replay auto-stop watchdog")

View File

@ -32,7 +32,7 @@ class StatsEmitter(threading.Thread):
self.config = config
self.stats_tracking = stats_tracking
self.stop_event = stop_event
self.hwaccel_errors: list[str] = []
self.hwaccel_errors: dict[str, float] = {}
self.stats_history: list[dict[str, Any]] = []
# create communication for stats

View File

@ -1,6 +1,7 @@
"""Utilities for stats."""
import asyncio
import logging
import os
import shutil
import time
@ -34,6 +35,10 @@ from frigate.util.services import (
)
from frigate.version import VERSION
logger = logging.getLogger(__name__)
HWACCEL_ERROR_COOLDOWN_SECONDS = 3600
def get_latest_version(config: FrigateConfig) -> str:
if not config.telemetry.version_check:
@ -167,7 +172,9 @@ def get_detector_stats(
def get_processing_stats(
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
config: FrigateConfig,
stats: dict[str, str],
hwaccel_errors: dict[str, float],
) -> None:
"""Get stats for cpu / gpu."""
@ -206,7 +213,9 @@ async def set_bandwidth_stats(config: FrigateConfig, all_stats: dict[str, Any])
async def set_gpu_stats(
config: FrigateConfig, all_stats: dict[str, Any], hwaccel_errors: list[str]
config: FrigateConfig,
all_stats: dict[str, Any],
hwaccel_errors: dict[str, float],
) -> None:
"""Parse GPUs from hwaccel args and use for stats."""
hwaccel_args = []
@ -231,12 +240,16 @@ async def set_gpu_stats(
stats: dict[str, dict] = {}
intel_gpu_collected = False
now = time.monotonic()
for args in hwaccel_args:
if args in hwaccel_errors:
# known erroring args should automatically return as error
stats["error-gpu"] = {"gpu": "", "mem": ""}
elif "cuvid" in args or "nvidia" in args:
last_error = hwaccel_errors.get(args)
if last_error is not None:
if now - last_error < HWACCEL_ERROR_COOLDOWN_SECONDS:
continue
hwaccel_errors.pop(args, None)
if "cuvid" in args or "nvidia" in args:
# nvidia GPU
nvidia_usage = get_nvidia_gpu_stats()
@ -253,7 +266,7 @@ async def set_gpu_stats(
else:
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
hwaccel_errors[args] = time.monotonic()
elif "nvmpi" in args or "jetson" in args:
# nvidia Jetson
jetson_usage = get_jetson_stats()
@ -262,7 +275,7 @@ async def set_gpu_stats(
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
else:
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
hwaccel_errors[args] = time.monotonic()
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
if not config.telemetry.stats.intel_gpu_stats:
continue
@ -280,7 +293,7 @@ async def set_gpu_stats(
stats[name] = entry
else:
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
hwaccel_errors[args] = time.monotonic()
elif "vaapi" in args:
if not config.telemetry.stats.amd_gpu_stats:
continue
@ -292,7 +305,7 @@ async def set_gpu_stats(
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
else:
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
hwaccel_errors.append(args)
hwaccel_errors[args] = time.monotonic()
elif "preset-rk" in args:
rga_usage = get_rockchip_gpu_stats()
@ -328,7 +341,9 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
def stats_snapshot(
config: FrigateConfig, stats_tracking: StatsTrackingTypes, hwaccel_errors: list[str]
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
hwaccel_errors: dict[str, float],
) -> dict[str, Any]:
"""Get a snapshot of the current stats that are being tracked."""
camera_metrics = stats_tracking["camera_metrics"]

View File

@ -0,0 +1,217 @@
"""Tests for Dispatcher runtime state persistence wiring."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.runtime_state import RuntimeStatePersistence
def _make_camera_mock(
*,
enabled: bool = True,
enabled_in_config: bool = True,
detect_enabled: bool = True,
record_enabled: bool = True,
record_enabled_in_config: bool = True,
snapshots_enabled: bool = True,
audio_enabled: bool = True,
audio_enabled_in_config: bool = True,
) -> MagicMock:
"""Build a camera config mock with the fields the in-scope handlers read."""
camera = MagicMock()
camera.enabled = enabled
camera.enabled_in_config = enabled_in_config
camera.detect.enabled = detect_enabled
camera.motion.enabled = True # avoid the detect→motion side-effect path
camera.record.enabled = record_enabled
camera.record.enabled_in_config = record_enabled_in_config
camera.snapshots.enabled = snapshots_enabled
camera.audio.enabled = audio_enabled
camera.audio.enabled_in_config = audio_enabled_in_config
return camera
def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
"""Construct a Dispatcher with the bare-minimum mocks the tests need."""
config = MagicMock()
config.cameras = cameras
config_updater = MagicMock()
onvif = MagicMock()
ptz_metrics: dict = {}
communicators: list = []
with (
patch("frigate.comms.dispatcher.CameraActivityManager"),
patch("frigate.comms.dispatcher.AudioActivityManager"),
):
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
class TestRestoreRuntimeState(unittest.TestCase):
"""Verify replay routes through handlers and tolerates missing entries."""
def setUp(self) -> None:
self.dispatcher = _build_dispatcher(
{
"front_door": _make_camera_mock(),
"back_yard": _make_camera_mock(),
}
)
# Swap each in-scope handler for a MagicMock so we can assert calls
# without exercising the handler's own logic.
self.handler_mocks: dict[str, MagicMock] = {}
for topic in ("enabled", "detect", "snapshots", "recordings", "audio"):
mock = MagicMock()
self.dispatcher._camera_settings_handlers[topic] = mock
self.handler_mocks[topic] = mock
def test_replays_each_stored_entry_through_its_handler(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(
return_value={
"front_door": {"detect": False, "recordings": False},
"back_yard": {"audio": False},
}
),
)
self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
self.handler_mocks["audio"].assert_called_once_with("back_yard", "OFF")
self.handler_mocks["enabled"].assert_not_called()
self.handler_mocks["snapshots"].assert_not_called()
def test_skips_unknown_cameras(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"removed_cam": {"detect": False}}),
)
self.dispatcher.restore_runtime_state()
for mock in self.handler_mocks.values():
mock.assert_not_called()
def test_skips_unknown_topics(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"front_door": {"some_old_topic": True}}),
)
self.dispatcher.restore_runtime_state()
for mock in self.handler_mocks.values():
mock.assert_not_called()
def test_continues_after_handler_exception(self) -> None:
self.handler_mocks["detect"].side_effect = RuntimeError("boom")
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(
return_value={
"front_door": {"detect": False, "recordings": False},
}
),
)
# Must not raise; the recordings handler must still run.
self.dispatcher.restore_runtime_state()
self.handler_mocks["recordings"].assert_called_once_with("front_door", "OFF")
def test_true_value_routes_as_on_payload(self) -> None:
self.dispatcher._runtime_state = MagicMock(
spec=RuntimeStatePersistence,
load=MagicMock(return_value={"front_door": {"detect": True}}),
)
self.dispatcher.restore_runtime_state()
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
class TestHandlersPersistViaSet(unittest.TestCase):
"""Verify each in-scope handler writes to the runtime state on success."""
def setUp(self) -> None:
self.tmp_dir = tempfile.mkdtemp()
self.config_path = os.path.join(self.tmp_dir, "config.yml")
with open(self.config_path, "w") as f:
f.write("")
self._patcher = patch(
"frigate.comms.runtime_state.find_config_file",
return_value=self.config_path,
)
self._patcher.start()
# Start with everything OFF so each ON payload triggers a real change
self.cameras = {
"front_door": _make_camera_mock(
enabled=False,
detect_enabled=False,
record_enabled=False,
snapshots_enabled=False,
audio_enabled=False,
)
}
self.dispatcher = _build_dispatcher(self.cameras)
def tearDown(self) -> None:
self._patcher.stop()
for name in os.listdir(self.tmp_dir):
os.remove(os.path.join(self.tmp_dir, name))
os.rmdir(self.tmp_dir)
def _stored_state(self) -> dict:
return RuntimeStatePersistence().load()
def test_enabled_handler_persists(self) -> None:
self.dispatcher._on_enabled_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"enabled": True}})
def test_detect_handler_persists(self) -> None:
self.dispatcher._on_detect_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"detect": True}})
def test_recordings_handler_persists(self) -> None:
self.dispatcher._on_recordings_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"recordings": True}})
def test_snapshots_handler_persists(self) -> None:
self.dispatcher._on_snapshots_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"snapshots": True}})
def test_audio_handler_persists(self) -> None:
self.dispatcher._on_audio_command("front_door", "ON")
self.assertEqual(self._stored_state(), {"front_door": {"audio": True}})
def test_enabled_in_config_gate_blocks_persistence(self) -> None:
"""An ON payload rejected by the gate must not be persisted."""
cam = self.cameras["front_door"]
cam.enabled_in_config = False
cam.record.enabled_in_config = False
cam.audio.enabled_in_config = False
self.dispatcher._on_enabled_command("front_door", "ON")
self.dispatcher._on_recordings_command("front_door", "ON")
self.dispatcher._on_audio_command("front_door", "ON")
self.assertEqual(self._stored_state(), {})
class TestClearPassthrough(unittest.TestCase):
"""The dispatcher's public clear methods delegate to the store."""
def test_clear_runtime_state_for_yaml_keys_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
keys = ["cameras.front_door.detect.enabled"]
dispatcher.clear_runtime_state_for_yaml_keys(keys)
dispatcher._runtime_state.clear_for_yaml_keys.assert_called_once_with(keys)
def test_clear_runtime_state_passthrough(self) -> None:
dispatcher = _build_dispatcher({})
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
dispatcher.clear_runtime_state()
dispatcher._runtime_state.clear_all.assert_called_once_with()
if __name__ == "__main__":
unittest.main()

View File

@ -727,6 +727,55 @@ class TestProfileManager(unittest.TestCase):
# Should not raise
json.dumps(api_base)
@patch.object(ProfileManager, "_persist_active_profile")
def test_activate_profile_clears_dispatcher_runtime_state(self, mock_persist):
"""User-initiated activation drops runtime overrides (steady-state rule)."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_deactivate_profile_clears_dispatcher_runtime_state(self, mock_persist):
"""Deactivating a profile also drops runtime overrides."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.reset_mock()
manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed", clear_runtime_overrides=False)
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_clears_when_active_profile_reapplies(self, mock_persist):
"""After /api/config/set, an active-profile re-application drops state."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
manager.activate_profile("armed")
dispatcher.clear_runtime_state.reset_mock()
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_called_once_with()
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
"""Plain /api/config/set without a profile doesn't trigger the broad clear."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
# No activate_profile call — config.active_profile is None
new_config = FrigateConfig(**self.config_data)
manager.update_config(new_config)
dispatcher.clear_runtime_state.assert_not_called()
class TestProfilePersistence(unittest.TestCase):
"""Test profile persistence to disk."""

View File

@ -0,0 +1,136 @@
"""Tests for RuntimeStatePersistence."""
import json
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.comms.runtime_state import RuntimeStatePersistence
class TestRuntimeStatePersistence(unittest.TestCase):
"""Unit tests for the JSON-backed runtime state store."""
def setUp(self) -> None:
self.tmp_dir = tempfile.mkdtemp()
self.config_path = os.path.join(self.tmp_dir, "config.yml")
# Touch a placeholder config.yml so find_config_file returns a real path
with open(self.config_path, "w") as f:
f.write("")
self._patcher = patch(
"frigate.comms.runtime_state.find_config_file",
return_value=self.config_path,
)
self._patcher.start()
self.store = RuntimeStatePersistence()
def tearDown(self) -> None:
self._patcher.stop()
for name in os.listdir(self.tmp_dir):
os.remove(os.path.join(self.tmp_dir, name))
os.rmdir(self.tmp_dir)
def test_load_returns_empty_when_file_missing(self) -> None:
self.assertEqual(self.store.load(), {})
def test_set_then_load_round_trip(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", True)
self.store.set("back_yard", "audio", False)
result = self.store.load()
self.assertEqual(
result,
{
"front_door": {"detect": False, "recordings": True},
"back_yard": {"audio": False},
},
)
def test_set_with_untracked_topic_is_noop(self) -> None:
self.store.set("front_door", "ptz_autotracker", True)
self.assertEqual(self.store.load(), {})
# File should not even be created if no tracked entries were written
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
self.assertFalse(os.path.exists(runtime_path))
def test_set_overwrites_previous_value(self) -> None:
self.store.set("front_door", "detect", True)
self.store.set("front_door", "detect", False)
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_load_returns_empty_when_file_corrupt(self) -> None:
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
with open(runtime_path, "w") as f:
f.write("{not valid json")
self.assertEqual(self.store.load(), {})
def test_load_handles_unexpected_top_level_shape(self) -> None:
runtime_path = os.path.join(self.tmp_dir, ".runtime_state.json")
with open(runtime_path, "w") as f:
json.dump(["unexpected", "list"], f)
self.assertEqual(self.store.load(), {})
def test_clear_for_yaml_keys_removes_matching_entries(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", False)
self.store.set("back_yard", "audio", False)
self.store.clear_for_yaml_keys(
[
"cameras.front_door.detect.enabled",
"cameras.back_yard.audio.enabled",
]
)
self.assertEqual(
self.store.load(),
{"front_door": {"recordings": False}},
)
def test_clear_for_yaml_keys_collapses_empty_camera_dict(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys(["cameras.front_door.detect.enabled"])
self.assertEqual(self.store.load(), {})
def test_clear_for_yaml_keys_ignores_unrelated_keys(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys(
[
"ui.theme",
"go2rtc.streams.x",
"cameras.front_door.ffmpeg.inputs",
"not_cameras.front_door.detect.enabled",
]
)
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_clear_for_yaml_keys_handles_empty_iterable(self) -> None:
self.store.set("front_door", "detect", False)
self.store.clear_for_yaml_keys([])
self.assertEqual(self.store.load(), {"front_door": {"detect": False}})
def test_camera_level_enabled_uses_top_level_yaml_key(self) -> None:
"""`enabled` topic maps to the camera-level `cameras.<cam>.enabled` key."""
self.store.set("front_door", "enabled", False)
self.store.clear_for_yaml_keys(["cameras.front_door.enabled"])
self.assertEqual(self.store.load(), {})
def test_clear_all_wipes_every_entry(self) -> None:
self.store.set("front_door", "detect", False)
self.store.set("front_door", "recordings", True)
self.store.set("back_yard", "audio", False)
self.store.clear_all()
self.assertEqual(self.store.load(), {})
def test_clear_all_is_safe_when_file_missing(self) -> None:
# No prior set() calls — file does not exist
self.store.clear_all()
self.assertEqual(self.store.load(), {})
if __name__ == "__main__":
unittest.main()

View File

@ -416,6 +416,11 @@ def get_intel_gpu_stats(
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
if not snapshot_a:
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 "",
)
return None
start = time.monotonic()
@ -424,6 +429,9 @@ def get_intel_gpu_stats(
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"
)
return None
def _new_engine_pct() -> dict[str, float]:
@ -464,6 +472,10 @@ 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)"
)
return None
names = intel_gpu_name_resolver.get_names()

View File

@ -177,6 +177,7 @@
"en": "English (English)",
"es": "Español (Spanish)",
"zhCN": "简体中文 (Simplified Chinese)",
"zhHant": "繁體中文 (Traditional Chinese)",
"hi": "हिन्दी (Hindi)",
"fr": "Français (French)",
"ar": "العربية (Arabic)",

View File

@ -1405,6 +1405,17 @@
"namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex"
},
"liveStreams": {
"streamNameLabel": "Stream name",
"streamNamePlaceholder": "e.g., Main HD Stream",
"go2rtcStreamLabel": "go2rtc stream",
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
"go2rtcStreamSearch": "Search or enter a stream name…",
"noGo2rtcStreams": "No go2rtc streams configured",
"availableStreams": "Available streams",
"useCustom": "Use \"{{value}}\"",
"addStream": "Add stream"
},
"timezone": {
"defaultOption": "Use browser timezone"
},

View File

@ -4,17 +4,26 @@ const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["stream_name", "height", "quality"],
fieldOrder: ["streams", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
},
global: {
restartRequired: ["stream_name", "height", "quality"],
restartRequired: ["streams", "height", "quality"],
hiddenFields: ["streams"],
},
camera: {
restartRequired: ["height", "quality"],
uiSchema: {
streams: {
"ui:field": "LiveStreamsField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
},
},
};

View File

@ -0,0 +1,346 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type LiveStreamsData = Record<string, string>;
type StreamValueComboboxProps = {
id: string;
value: string;
options: string[];
disabled?: boolean;
readonly?: boolean;
onChange: (next: string) => void;
};
function StreamValueCombobox({
id,
value,
options,
disabled,
readonly,
onChange,
}: StreamValueComboboxProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const trimmedSearch = searchValue.trim();
const matchesOption = useMemo(
() => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()),
[options, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesOption;
const commit = (next: string) => {
onChange(next);
setSearchValue("");
setOpen(false);
};
const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", {
ns: "views/settings",
});
const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", {
ns: "views/settings",
});
const noStreams = t("configForm.liveStreams.noGo2rtcStreams", {
ns: "views/settings",
});
const availableHeading = t("configForm.liveStreams.availableStreams", {
ns: "views/settings",
});
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"w-full justify-between font-normal",
!value && "text-muted-foreground",
)}
>
<span className="truncate">{value || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.liveStreams.useCustom", {
ns: "views/settings",
value: trimmedSearch,
})}
</CommandItem>
</CommandGroup>
)}
{options.length > 0 ? (
<CommandGroup heading={availableHeading}>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => commit(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{noStreams}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const data: LiveStreamsData = useMemo(() => {
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
return {};
}
return formData as LiveStreamsData;
}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
const id = idSchema?.$id ?? props.name;
const sectionPrefix = formContext?.sectionI18nPrefix;
const title =
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
const description =
t(`${sectionPrefix}.${id}.description`) ??
(schema as RJSFSchema).description;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") return [];
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const isModified = useMemo(() => {
const baselineRoot = formContext?.baselineFormData;
const baselineValue = baselineRoot
? get(baselineRoot, fieldPath)
: undefined;
return isSubtreeModified(
data,
baselineValue,
formContext?.overrides,
fieldPath,
formContext?.formData,
);
}, [fieldPath, formContext, data]);
const handleAddEntry = useCallback(() => {
const next = { ...data, "": "" };
onChange(next, fieldPath);
}, [data, fieldPath, onChange]);
const handleRemoveEntry = useCallback(
(key: string) => {
const next = { ...data };
delete next[key];
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleRenameKey = useCallback(
(oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const next: LiveStreamsData = {};
for (const [k, v] of Object.entries(data)) {
if (k === oldKey) {
next[newKey] = v;
} else {
next[k] = v;
}
}
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const handleUpdateValue = useCallback(
(key: string, value: string) => {
const next = { ...data, [key]: value };
onChange(next, fieldPath);
},
[data, fieldPath, onChange],
);
const baseId = idSchema?.$id || "live_streams";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
const streamNameLabel = t("configForm.liveStreams.streamNameLabel", {
ns: "views/settings",
});
const streamNamePlaceholder = t(
"configForm.liveStreams.streamNamePlaceholder",
{ ns: "views/settings" },
);
const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", {
ns: "views/settings",
});
const addStreamLabel = t("configForm.liveStreams.addStream", {
ns: "views/settings",
});
return (
<Card className="w-full">
<CardHeader className="p-4">
<CardTitle className={cn("text-sm", isModified && "text-unsaved")}>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, value], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="grid grid-cols-12 items-end gap-2 rounded-md border p-3"
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
<Label htmlFor={`${entryId}-value`}>{go2rtcStreamLabel}</Label>
<StreamValueCombobox
id={`${entryId}-value`}
value={value}
options={go2rtcStreamNames}
disabled={disabled}
readonly={readonly}
onChange={(next) => handleUpdateValue(key, next)}
/>
</div>
<div className="col-span-2 flex justify-end md:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddEntry}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{addStreamLabel}
</Button>
</div>
</CardContent>
</Card>
);
}
export default LiveStreamsField;

View File

@ -2,3 +2,4 @@
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";

View File

@ -51,6 +51,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
import { LiveStreamsField } from "./fields/LiveStreamsField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
@ -109,5 +110,6 @@ export const frigateTheme: FrigateTheme = {
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
LiveStreamsField: LiveStreamsField,
},
};

View File

@ -109,6 +109,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
"nb-NO": "nb",
"yue-Hant": "yue",
"zh-CN": "zhCN",
"zh-Hant": "zhHant",
"pt-BR": "ptBR",
};

View File

@ -34,6 +34,8 @@ const localeMap: Record<string, () => Promise<Locale>> = {
sk: () => import("date-fns/locale/sk").then((module) => module.sk),
"yue-Hant": () =>
import("date-fns/locale/zh-HK").then((module) => module.zhHK),
"zh-Hant": () =>
import("date-fns/locale/zh-TW").then((module) => module.zhTW),
lt: () => import("date-fns/locale/lt").then((module) => module.lt),
th: () => import("date-fns/locale/th").then((module) => module.th),
ca: () => import("date-fns/locale/ca").then((module) => module.ca),

View File

@ -29,6 +29,7 @@ export const supportedLanguageKeys = [
"nb-NO",
"sv",
"zh-CN",
"zh-Hant",
"yue-Hant",
"ja",
"vi",