mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 11:19:02 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f91ca32bb | ||
|
|
c98efd4a02 | ||
|
|
38d0fa8b06 | ||
|
|
d7403c1010 | ||
|
|
3e0f6c85de | ||
|
|
45d3c6389e | ||
|
|
761a431c55 | ||
|
|
ce0197cc8f | ||
|
|
6f80bcd19f | ||
|
|
d02a1156b7 | ||
|
|
c17538aff9 | ||
|
|
dd7e9f1bc5 | ||
|
|
48aaafba3c | ||
|
|
f1028d0c36 | ||
|
|
70d629bf93 | ||
|
|
c406a93d3d |
@@ -12,6 +12,7 @@ config/*
|
||||
models
|
||||
*.mp4
|
||||
*.db
|
||||
*.db-*
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
|
||||
@@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_
|
||||
FROM base_host AS ov-converter
|
||||
ARG DEBIAN_FRONTEND
|
||||
|
||||
# Install OpenVino Runtime and Dev library
|
||||
# Install OpenVINO for model conversion
|
||||
COPY docker/main/requirements-ov.txt /requirements-ov.txt
|
||||
RUN apt-get -qq update \
|
||||
&& apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \
|
||||
&& apt-get -qq install -y wget python3 python3-distutils \
|
||||
&& wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
&& python3 get-pip.py "pip" \
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
import openvino as ov
|
||||
from openvino.tools import mo
|
||||
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
||||
|
||||
ov_model = mo.convert_model(
|
||||
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
|
||||
frontend converts the Object Detection API frozen graph natively; the four TF
|
||||
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
|
||||
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
|
||||
BGR to match the legacy reverse_input_channels behavior.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import openvino as ov
|
||||
from openvino import opset8 as ops
|
||||
from openvino.preprocess import PrePostProcessor
|
||||
|
||||
model = ov.convert_model(
|
||||
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
|
||||
compress_to_fp16=True,
|
||||
transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
|
||||
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
|
||||
reverse_input_channels=True,
|
||||
input=[("image_tensor:0", [1, 300, 300, 3])],
|
||||
)
|
||||
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")
|
||||
|
||||
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
|
||||
boxes = model.output("detection_boxes:0").get_node().input_value(0)
|
||||
classes = model.output("detection_classes:0").get_node().input_value(0)
|
||||
scores = model.output("detection_scores:0").get_node().input_value(0)
|
||||
|
||||
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
|
||||
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
|
||||
classes = ops.unsqueeze(classes, 2)
|
||||
scores = ops.unsqueeze(scores, 2)
|
||||
image_id = ops.multiply(scores, np.float32(0.0))
|
||||
|
||||
detections = ops.concat([image_id, classes, scores, boxes], 2)
|
||||
detections = ops.unsqueeze(detections, 1)
|
||||
detections.output(0).get_tensor().set_names({"detection_out"})
|
||||
|
||||
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
|
||||
|
||||
ppp = PrePostProcessor(model)
|
||||
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
|
||||
ppp.input().preprocess().reverse_channels()
|
||||
model = ppp.build()
|
||||
|
||||
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
numpy
|
||||
tensorflow
|
||||
openvino-dev>=2024.0.0
|
||||
openvino >= 2026.2.0
|
||||
|
||||
@@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o
|
||||
|
||||
#### Transcription and translation of `speech` audio events
|
||||
|
||||
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane.
|
||||
Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane.
|
||||
|
||||
In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
|
||||
|
||||
@@ -294,7 +294,7 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m
|
||||
|
||||
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
||||
|
||||
If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||
If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button (the microphone icon) in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||
|
||||
Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available.
|
||||
|
||||
|
||||
@@ -262,6 +262,19 @@ In this example:
|
||||
|
||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||
|
||||
:::note
|
||||
|
||||
If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy:
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
frigate.api.auth: debug
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -232,7 +232,21 @@ Once front-facing images are performing well, start choosing slightly off-angle
|
||||
|
||||
Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above.
|
||||
|
||||
1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
|
||||
1. Enable debug logs to see exactly what Frigate is doing.
|
||||
- Enable debug logs for face recognition by adding `frigate.data_processing.real_time.face: debug` to your `logger` configuration. Restart Frigate after this change.
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.data_processing.real_time.face: debug
|
||||
```
|
||||
|
||||
- These logs report where the pipeline stopped for each `person` object, such as no face being found within the person's bounding box, the detected face being smaller than `min_area`, or a face being recognized but scoring too low.
|
||||
- If you see no face-related messages at all, also add `frigate.embeddings.maintainer: debug` to confirm that the face processor was created at startup and that `person` updates are reaching it.
|
||||
|
||||
2. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
|
||||
|
||||
If you are using a Frigate+ or `face` detecting model:
|
||||
- Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`.
|
||||
@@ -242,7 +256,7 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
|
||||
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
|
||||
- You may need to lower your `detection_threshold` if faces are not being detected.
|
||||
|
||||
2. Any detected faces will then be _recognized_.
|
||||
3. Any detected faces will then be _recognized_.
|
||||
- Make sure you have trained at least one face per the recommendations above.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ All llama.cpp native options can be passed through `provider_options`, including
|
||||
- Set **Provider** to `llamacpp`
|
||||
- Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`)
|
||||
- Set **Model** to the name of your model
|
||||
- Under **Provider Options**, set `context_size` to tell Frigate your context size so it can send the appropriate amount of information
|
||||
- Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -89,12 +89,14 @@ genai:
|
||||
base_url: http://localhost:8080
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information.
|
||||
context_size: 16000 # Optional, overrides the context size reported by the server.
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
|
||||
|
||||
### Ollama
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
|
||||
@@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic
|
||||
|
||||
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
|
||||
|
||||
### How do I make a YAML profile track no objects at all?
|
||||
|
||||
Set the tracked object list explicitly to an empty list in the profile:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
profiles:
|
||||
home:
|
||||
objects:
|
||||
track: []
|
||||
```
|
||||
|
||||
Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`.
|
||||
|
||||
### Why are some settings missing when I configure a profile override?
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
@@ -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
|
||||
|
||||
<DockerComposeGenerator/>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="original" label="Example Docker Compose File">
|
||||
```yaml
|
||||
|
||||
@@ -428,3 +428,19 @@ You'll want to:
|
||||
- [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="my-timeline-previews-are-black-after-restarting-frigate-or-recreating-the-container" question="My timeline previews are black after restarting Frigate or recreating the container. Why?">
|
||||
|
||||
The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**.
|
||||
|
||||
In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour.
|
||||
|
||||
This is expected behavior, not a bug:
|
||||
|
||||
- Previews for hours that already completed and were written to disk are unaffected.
|
||||
- The next full hour after a restart will generate previews normally.
|
||||
- This is unrelated to `shm_size`; increasing shared memory does not change it.
|
||||
|
||||
To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga
|
||||
- **Rename** it, and
|
||||
- **Delete** it: deleting is the only way an export is removed.
|
||||
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases).
|
||||
You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there.
|
||||
|
||||
## Cases
|
||||
|
||||
|
||||
Generated
+3
-4
@@ -19,7 +19,6 @@
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
@@ -23020,9 +23019,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
||||
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"http-parser-js": ">=0.5.1",
|
||||
|
||||
Vendored
+5
@@ -8244,6 +8244,11 @@ components:
|
||||
properties:
|
||||
provider:
|
||||
$ref: '#/components/schemas/GenAIProviderEnum'
|
||||
name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Name
|
||||
api_key:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
+16
-16
@@ -31,6 +31,7 @@ from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
@@ -195,7 +196,7 @@ def genai_models(request: Request):
|
||||
"before saving the configuration."
|
||||
),
|
||||
)
|
||||
async def genai_probe(body: GenAIProbeBody):
|
||||
async def genai_probe(request: Request, body: GenAIProbeBody):
|
||||
load_providers()
|
||||
|
||||
provider_cls = PROVIDERS.get(body.provider)
|
||||
@@ -205,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
content={"success": False, "message": "Unknown provider"},
|
||||
)
|
||||
|
||||
api_key = body.api_key
|
||||
if api_key == REDACTED_CREDENTIAL_SENTINEL:
|
||||
saved_cfg = (
|
||||
request.app.frigate_config.genai.get(body.name) if body.name else None
|
||||
)
|
||||
api_key = saved_cfg.api_key if saved_cfg else None
|
||||
|
||||
# The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via
|
||||
# provider_options; other plugins use GenAIClient.timeout passed below.
|
||||
# Don't inject timeout for Gemini — its HttpOptions interprets the value
|
||||
@@ -216,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody):
|
||||
try:
|
||||
transient_cfg = GenAIConfig(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
api_key=api_key,
|
||||
base_url=body.base_url,
|
||||
provider_options=probe_provider_options,
|
||||
# model is required by the schema but irrelevant for listing.
|
||||
@@ -915,19 +923,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
if request.app.profile_manager is not None:
|
||||
request.app.profile_manager.update_config(config)
|
||||
|
||||
if request.app.stats_emitter is not None:
|
||||
request.app.stats_emitter.config = config
|
||||
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.config = config
|
||||
for comm in request.app.dispatcher.comms:
|
||||
comm.config = config
|
||||
swap_runtime_config(request.app, config)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -971,7 +967,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,
|
||||
|
||||
@@ -25,6 +25,7 @@ from frigate.api.auth import (
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.api.defs.request.app_body import CameraSetBody
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -1254,9 +1255,14 @@ async def delete_camera(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Update runtime config
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
# rebind every collaborator to the new config and re-layer runtime
|
||||
# toggles for the surviving cameras, same as /api/config/set
|
||||
swap_runtime_config(request.app, config)
|
||||
|
||||
# drop the deleted camera's persisted overrides so a camera later
|
||||
# added under the same name doesn't inherit them
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
|
||||
|
||||
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||
request.app.config_publisher.publish_update(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Shared helpers for applying a freshly parsed config to the running app."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
"""Point every long-lived collaborator at a newly parsed config object.
|
||||
|
||||
Both /api/config/set and camera deletion re-parse yaml into a fresh
|
||||
FrigateConfig and must rebind the same set of references, or the API and
|
||||
the dispatcher drift onto different objects (the API reports one camera
|
||||
state while the dispatcher acts on another). Runtime toggle overrides are
|
||||
re-layered last: the swap rebuilt every camera from yaml, so without this a
|
||||
camera the user turned off would silently come back on.
|
||||
"""
|
||||
app.frigate_config = config
|
||||
app.genai_manager.update_config(config)
|
||||
|
||||
if app.profile_manager is not None:
|
||||
app.profile_manager.update_config(config)
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
for comm in app.dispatcher.comms:
|
||||
comm.config = config
|
||||
|
||||
# workers still hold the live toggle values, so correct only the
|
||||
# config object here rather than re-broadcasting every override
|
||||
app.dispatcher.reapply_runtime_state_to_config()
|
||||
@@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel):
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
provider: GenAIProviderEnum
|
||||
name: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
provider_options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -404,38 +404,64 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
def restore_runtime_state(self) -> None:
|
||||
def apply_runtime_state(self) -> dict[str, dict[str, bool]]:
|
||||
"""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.
|
||||
Routing through the handlers (rather than mutating config directly) is
|
||||
deliberate: they publish the ``config_updater`` broadcast and the
|
||||
retained MQTT state as a side effect, so worker processes and the UI
|
||||
converge on the replayed value. Unknown cameras and topics are skipped;
|
||||
handler exceptions are logged and replay continues for the rest.
|
||||
|
||||
Returns:
|
||||
The entries handed to a handler without raising, keyed by camera
|
||||
then topic. A handler can still refuse the value internally (an ON
|
||||
payload for a camera that is not enabled_in_config, for example),
|
||||
so this is not proof the override took effect.
|
||||
"""
|
||||
state = self._runtime_state.load()
|
||||
applied: dict[str, dict[str, bool]] = {}
|
||||
|
||||
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",
|
||||
"Failed to apply runtime state %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
)
|
||||
continue
|
||||
|
||||
applied.setdefault(camera_name, {})[topic] = value
|
||||
|
||||
return applied
|
||||
|
||||
def restore_runtime_state(self) -> None:
|
||||
"""Replay persisted runtime overrides once Frigate startup completes.
|
||||
|
||||
Called after every ``config_updater`` subscriber is up so the resulting
|
||||
broadcasts are not dropped by ZMQ PUB/SUB.
|
||||
"""
|
||||
for camera_name, features in self.apply_runtime_state().items():
|
||||
for topic, value in features.items():
|
||||
logger.info(
|
||||
"Restored runtime state: %s.%s=%s",
|
||||
camera_name,
|
||||
topic,
|
||||
payload,
|
||||
"ON" if value else "OFF",
|
||||
)
|
||||
|
||||
def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
@@ -458,6 +484,56 @@ class Dispatcher:
|
||||
"""
|
||||
self._runtime_state.clear_all()
|
||||
|
||||
def clear_runtime_state_for_camera(self, camera: str) -> None:
|
||||
"""Drop all persisted runtime overrides for a deleted camera.
|
||||
|
||||
Called by camera deletion so a camera later added under the same name
|
||||
does not inherit the removed camera's stale toggles.
|
||||
"""
|
||||
self._runtime_state.clear_camera(camera)
|
||||
|
||||
def reapply_runtime_state_to_config(self) -> None:
|
||||
"""Re-apply persisted runtime overrides to the swapped-in config object.
|
||||
|
||||
After config/set (or a camera delete) parses fresh yaml and swaps the
|
||||
config, the worker processes still hold the live toggle values and the
|
||||
overrides are already on disk, so only the in-process config object is
|
||||
out of date. Unlike apply_runtime_state (used at startup, where workers
|
||||
must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects
|
||||
the config the API and dispatcher read.
|
||||
|
||||
The field mutations and gates mirror the _on_*_command handlers; keep
|
||||
the two in sync if a tracked toggle is added or its gate changes.
|
||||
"""
|
||||
state = self._runtime_state.load()
|
||||
|
||||
for camera_name, features in state.items():
|
||||
camera = self.config.cameras.get(camera_name)
|
||||
|
||||
if camera is None:
|
||||
continue
|
||||
|
||||
for topic, value in features.items():
|
||||
if topic == "enabled":
|
||||
if value and not camera.enabled_in_config:
|
||||
continue
|
||||
camera.enabled = value
|
||||
elif topic == "detect":
|
||||
camera.detect.enabled = value
|
||||
# detection requires motion, mirror the handler coupling
|
||||
if value and not camera.motion.enabled:
|
||||
camera.motion.enabled = True
|
||||
elif topic == "snapshots":
|
||||
camera.snapshots.enabled = value
|
||||
elif topic == "recordings":
|
||||
if value and not camera.record.enabled_in_config:
|
||||
continue
|
||||
camera.record.enabled = value
|
||||
elif topic == "audio":
|
||||
if value and not camera.audio.enabled_in_config:
|
||||
continue
|
||||
camera.audio.enabled = value
|
||||
|
||||
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for detect topic."""
|
||||
detect_settings = self.config.cameras[camera_name].detect
|
||||
@@ -588,6 +664,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:
|
||||
|
||||
@@ -96,6 +96,25 @@ class RuntimeStatePersistence:
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state")
|
||||
|
||||
def clear_camera(self, camera: str) -> None:
|
||||
"""Drop every stored override for a single camera.
|
||||
|
||||
Called when a camera is deleted so a camera later added under the same
|
||||
name does not inherit the removed camera's stale toggles.
|
||||
"""
|
||||
try:
|
||||
with FileLock(self._lock_path, timeout=self._lock_timeout):
|
||||
data = self._read_locked()
|
||||
cameras = data.get("cameras")
|
||||
if not isinstance(cameras, dict) or camera not in cameras:
|
||||
return
|
||||
del cameras[camera]
|
||||
self._write_locked(data)
|
||||
except Timeout:
|
||||
logger.error("Timed out clearing runtime state for camera")
|
||||
except OSError:
|
||||
logger.exception("Failed to clear runtime state for camera")
|
||||
|
||||
def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None:
|
||||
"""Remove stored entries whose YAML key was just rewritten.
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
@@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -141,6 +141,11 @@ class ProfileManager:
|
||||
Preserves active profile state: re-snapshots base configs from the new
|
||||
(freshly parsed) config, then re-applies profile overrides if a profile
|
||||
was active.
|
||||
|
||||
Deliberately does not clear the dispatcher's runtime overrides. This is
|
||||
the config-save path, not a profile switch: the save only invalidates
|
||||
the toggles it rewrote in yaml, which /api/config/set already clears by
|
||||
key. The broad wipe belongs to activate_profile alone.
|
||||
"""
|
||||
current_active = self.config.active_profile
|
||||
self.config = new_config
|
||||
@@ -164,10 +169,6 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
# 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: str | None,
|
||||
|
||||
@@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
|
||||
]
|
||||
|
||||
if face_frame.size == 0:
|
||||
logger.debug(f"Empty face crop for {id}")
|
||||
return
|
||||
|
||||
res = self.recognizer.classify(face_frame)
|
||||
|
||||
if not res:
|
||||
|
||||
@@ -192,7 +192,7 @@ class LlamaCppClient(GenAIClient):
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self.get_context_size(),
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
+10
-9
@@ -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 (
|
||||
|
||||
@@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp):
|
||||
"models": ["fake-model-a", "fake-model-b"],
|
||||
}
|
||||
|
||||
def test_genai_probe_resolves_sentinel_to_saved_api_key(self):
|
||||
# After a save the UI's api_key field holds the redaction sentinel;
|
||||
# the probe must substitute the saved key for the named entry instead
|
||||
# of sending the literal sentinel to the provider (GH discussion 23754).
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
self.minimal_config["genai"] = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-saved",
|
||||
"base_url": "https://example.invalid",
|
||||
"model": "fake-model",
|
||||
}
|
||||
}
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
"base_url": "https://example.invalid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert probed_keys == ["sk-saved"]
|
||||
|
||||
def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self):
|
||||
# If the sentinel arrives for an entry that has no saved config, the
|
||||
# probe must drop the key entirely rather than leak the sentinel.
|
||||
probed_keys: list[str | None] = []
|
||||
|
||||
class CapturingClient(GenAIClient):
|
||||
def list_models(self):
|
||||
probed_keys.append(self.genai_config.api_key)
|
||||
return ["fake-model"]
|
||||
|
||||
app = super().create_app()
|
||||
|
||||
with (
|
||||
AuthTestClient(app) as client,
|
||||
patch.dict(
|
||||
frigate.genai.PROVIDERS,
|
||||
{GenAIProviderEnum.openai: CapturingClient},
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/genai/probe",
|
||||
json={
|
||||
"provider": "openai",
|
||||
"name": "llm",
|
||||
"api_key": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert probed_keys == [None]
|
||||
|
||||
def test_genai_probe_empty_list_is_treated_as_failure(self):
|
||||
# The plugin's list_models() returns [] on connection failure rather
|
||||
# than raising. The endpoint should surface that as success=false so
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for the camera delete endpoint's runtime config handling."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestDeleteCameraRuntimeConfig(BaseTestHttp):
|
||||
"""Deleting a camera must keep the API and dispatcher on the same config."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_yard": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 720, "width": 1280, "fps": 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def _write_config_file(self):
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def _create_app_with_dispatcher(self, dispatcher):
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
dispatcher=dispatcher,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
return app, mock_publisher
|
||||
|
||||
@patch("frigate.api.camera.requests.delete")
|
||||
@patch("frigate.api.camera.cleanup_camera_files")
|
||||
@patch("frigate.api.camera.cleanup_camera_db")
|
||||
@patch("frigate.api.camera.find_config_file")
|
||||
def test_delete_syncs_dispatcher_and_prunes_runtime_state(
|
||||
self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete
|
||||
):
|
||||
"""Deleting a camera swaps every config reference and prunes its state."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
mock_cleanup_db.return_value = ({}, [])
|
||||
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.comms = []
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_dispatcher(dispatcher)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.delete("/cameras/front_door")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["success"])
|
||||
|
||||
# the dispatcher must be moved onto the same new object the API
|
||||
# now serves, and that object must no longer contain the camera
|
||||
self.assertIs(dispatcher.config, app.frigate_config)
|
||||
self.assertNotIn("front_door", dispatcher.config.cameras)
|
||||
self.assertIn("back_yard", dispatcher.config.cameras)
|
||||
|
||||
# surviving cameras' overrides are re-layered onto the new object
|
||||
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
|
||||
# the deleted camera's persisted overrides are pruned
|
||||
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
|
||||
"front_door"
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -91,6 +91,123 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
|
||||
return app, mock_publisher
|
||||
|
||||
def _create_app_with_dispatcher(self, dispatcher):
|
||||
"""Create app with a mocked config publisher and a real-ish dispatcher."""
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
dispatcher=dispatcher,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
return {"username": username, "role": role}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
return app, mock_publisher
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config):
|
||||
"""A camera turned off at runtime stays off when another camera is saved."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.comms = []
|
||||
|
||||
# front_door was turned off via the UI: the override is on disk, and
|
||||
# yaml still says enabled: true. Stand in for the real replay, which
|
||||
# reads dispatcher.config - the object the endpoint just swapped in.
|
||||
def fake_reapply():
|
||||
dispatcher.config.cameras["front_door"].enabled = False
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_dispatcher(dispatcher)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {"back_yard": {"detect": {"fps": 7}}}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["success"])
|
||||
|
||||
# the swap must be repaired: the new config object the API and
|
||||
# dispatcher now share has to still show front_door as off
|
||||
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
self.assertFalse(app.frigate_config.cameras["front_door"].enabled)
|
||||
self.assertIs(dispatcher.config, app.frigate_config)
|
||||
|
||||
# yaml-wins ordering: the surgical clear for rewritten keys
|
||||
# must run before the replay, or a save that rewrote a toggle
|
||||
# would have its old override resurrected
|
||||
call_names = [name for name, _, _ in dispatcher.mock_calls]
|
||||
self.assertLess(
|
||||
call_names.index("clear_runtime_state_for_yaml_keys"),
|
||||
call_names.index("reapply_runtime_state_to_config"),
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_no_reapply_when_config_is_not_swapped(self, mock_find_config):
|
||||
"""A restart-required save with no update topic never swaps, so no replay."""
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.comms = []
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_dispatcher(dispatcher)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"mqtt": {"host": "other"}},
|
||||
"requires_restart": 1,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
dispatcher.reapply_runtime_state_to_config.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def _write_config_file(self):
|
||||
"""Write the minimal config to a temp YAML file and return the path."""
|
||||
yaml = ruamel.yaml.YAML()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
|
||||
self.rtsp_with_special_pass = (
|
||||
"rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live"
|
||||
)
|
||||
self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live"
|
||||
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
|
||||
|
||||
def test_cleanup(self):
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the shared runtime config swap helper."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
|
||||
|
||||
class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
"""swap_runtime_config rebinds every collaborator to the new config."""
|
||||
|
||||
def _make_app(self) -> MagicMock:
|
||||
app = MagicMock()
|
||||
app.dispatcher.comms = [MagicMock(), MagicMock()]
|
||||
return app
|
||||
|
||||
def test_rebinds_all_references(self) -> None:
|
||||
app = self._make_app()
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
self.assertIs(app.frigate_config, config)
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
app.profile_manager.update_config.assert_called_once_with(config)
|
||||
self.assertIs(app.stats_emitter.config, config)
|
||||
self.assertIs(app.dispatcher.config, config)
|
||||
for comm in app.dispatcher.comms:
|
||||
self.assertIs(comm.config, config)
|
||||
|
||||
def test_reapplies_runtime_state_after_swap(self) -> None:
|
||||
app = self._make_app()
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
||||
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
|
||||
def test_tolerates_missing_optional_collaborators(self) -> None:
|
||||
app = MagicMock()
|
||||
app.profile_manager = None
|
||||
app.stats_emitter = None
|
||||
app.dispatcher = None
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
# must not raise when the optional collaborators are absent
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
self.assertIs(app.frigate_config, config)
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -126,6 +126,40 @@ class TestRestoreRuntimeState(unittest.TestCase):
|
||||
self.dispatcher.restore_runtime_state()
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
|
||||
|
||||
def test_apply_runtime_state_replays_through_handlers(self) -> None:
|
||||
"""The extracted method replays every stored entry."""
|
||||
with patch.object(
|
||||
self.dispatcher._runtime_state,
|
||||
"load",
|
||||
return_value={"front_door": {"enabled": False, "detect": True}},
|
||||
):
|
||||
self.dispatcher.apply_runtime_state()
|
||||
|
||||
self.handler_mocks["enabled"].assert_called_once_with("front_door", "OFF")
|
||||
self.handler_mocks["detect"].assert_called_once_with("front_door", "ON")
|
||||
|
||||
def test_apply_runtime_state_returns_applied_entries(self) -> None:
|
||||
"""Callers get back what was replayed, for logging and assertions."""
|
||||
with patch.object(
|
||||
self.dispatcher._runtime_state,
|
||||
"load",
|
||||
return_value={"front_door": {"enabled": False}, "nope": {"enabled": True}},
|
||||
):
|
||||
applied = self.dispatcher.apply_runtime_state()
|
||||
|
||||
self.assertEqual(applied, {"front_door": {"enabled": False}})
|
||||
|
||||
def test_restore_runtime_state_still_replays(self) -> None:
|
||||
"""The startup entry point keeps working after the extraction."""
|
||||
with patch.object(
|
||||
self.dispatcher._runtime_state,
|
||||
"load",
|
||||
return_value={"back_yard": {"snapshots": False}},
|
||||
):
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.handler_mocks["snapshots"].assert_called_once_with("back_yard", "OFF")
|
||||
|
||||
|
||||
class TestHandlersPersistViaSet(unittest.TestCase):
|
||||
"""Verify each in-scope handler writes to the runtime state on success."""
|
||||
@@ -212,6 +246,122 @@ class TestClearPassthrough(unittest.TestCase):
|
||||
dispatcher.clear_runtime_state()
|
||||
dispatcher._runtime_state.clear_all.assert_called_once_with()
|
||||
|
||||
def test_clear_runtime_state_for_camera_passthrough(self) -> None:
|
||||
dispatcher = _build_dispatcher({})
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher.clear_runtime_state_for_camera("front_door")
|
||||
dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door")
|
||||
|
||||
|
||||
class TestReapplyRuntimeStateToConfig(unittest.TestCase):
|
||||
"""The silent re-apply corrects the config object with no side effects."""
|
||||
|
||||
def _dispatcher_with(
|
||||
self, cameras: dict[str, MagicMock], state: dict
|
||||
) -> Dispatcher:
|
||||
dispatcher = _build_dispatcher(cameras)
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher._runtime_state.load.return_value = state
|
||||
dispatcher.publish = MagicMock()
|
||||
return dispatcher
|
||||
|
||||
def test_mutates_every_tracked_field(self) -> None:
|
||||
cameras = {"front_door": _make_camera_mock()}
|
||||
dispatcher = self._dispatcher_with(
|
||||
cameras,
|
||||
{
|
||||
"front_door": {
|
||||
"enabled": False,
|
||||
"detect": False,
|
||||
"snapshots": False,
|
||||
"recordings": False,
|
||||
"audio": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
cam = cameras["front_door"]
|
||||
self.assertFalse(cam.enabled)
|
||||
self.assertFalse(cam.detect.enabled)
|
||||
self.assertFalse(cam.snapshots.enabled)
|
||||
self.assertFalse(cam.record.enabled)
|
||||
self.assertFalse(cam.audio.enabled)
|
||||
|
||||
def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None:
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": _make_camera_mock()},
|
||||
{"front_door": {"enabled": False}},
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
dispatcher.config_updater.publish_update.assert_not_called()
|
||||
dispatcher._runtime_state.set.assert_not_called()
|
||||
dispatcher.publish.assert_not_called()
|
||||
|
||||
def test_respects_enabled_in_config_gate(self) -> None:
|
||||
# an ON override for a camera disabled in yaml must not enable it
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(enabled=False, enabled_in_config=False)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertFalse(cameras["front_door"].enabled)
|
||||
|
||||
def test_respects_recordings_and_audio_gates(self) -> None:
|
||||
# ON overrides for recordings/audio not enabled in yaml must be ignored
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(
|
||||
record_enabled=False,
|
||||
record_enabled_in_config=False,
|
||||
audio_enabled=False,
|
||||
audio_enabled_in_config=False,
|
||||
)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(
|
||||
cameras, {"front_door": {"recordings": True, "audio": True}}
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertFalse(cameras["front_door"].record.enabled)
|
||||
self.assertFalse(cameras["front_door"].audio.enabled)
|
||||
|
||||
def test_applies_on_override_when_gate_passes(self) -> None:
|
||||
# a camera off in yaml but enabled_in_config keeps its runtime-on state
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(enabled=False, enabled_in_config=True)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertTrue(cameras["front_door"].enabled)
|
||||
|
||||
def test_detect_on_couples_motion(self) -> None:
|
||||
cam = _make_camera_mock(detect_enabled=False)
|
||||
cam.motion.enabled = False
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": cam}, {"front_door": {"detect": True}}
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertTrue(cam.detect.enabled)
|
||||
self.assertTrue(cam.motion.enabled)
|
||||
|
||||
def test_skips_camera_not_in_config(self) -> None:
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}}
|
||||
)
|
||||
|
||||
# a stale entry for a deleted camera must be ignored, not raise
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -491,6 +491,34 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
|
||||
self.assertEqual(final["content"], "ok")
|
||||
|
||||
def _validated_client(self, server_context_size, provider_options=None):
|
||||
"""Build a client as if the server reported the given context size."""
|
||||
cfg = GenAIConfig(
|
||||
provider="llamacpp",
|
||||
model="m",
|
||||
base_url="http://localhost:9999",
|
||||
provider_options=provider_options or {},
|
||||
)
|
||||
info = {
|
||||
"context_size": server_context_size,
|
||||
"supports_vision": False,
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||
with patch.object(cls, "_get_model_info", return_value=info):
|
||||
return cls(cfg, timeout=5)
|
||||
|
||||
def test_server_context_size_used_without_override(self):
|
||||
client = self._validated_client(4096)
|
||||
self.assertEqual(client.get_context_size(), 4096)
|
||||
|
||||
def test_provider_options_context_size_overrides_server(self):
|
||||
client = self._validated_client(4096, {"context_size": 32768})
|
||||
self.assertEqual(client.get_context_size(), 32768)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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%",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -786,8 +786,15 @@ class TestProfileManager(unittest.TestCase):
|
||||
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."""
|
||||
def test_update_config_preserves_runtime_state_with_active_profile(
|
||||
self, mock_persist
|
||||
):
|
||||
"""A config/set save must not wipe overrides it never rewrote.
|
||||
|
||||
The save path clears matching entries itself via
|
||||
clear_runtime_state_for_yaml_keys; a broad wipe here would drop
|
||||
overrides for unrelated cameras.
|
||||
"""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
@@ -795,7 +802,20 @@ class TestProfileManager(unittest.TestCase):
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_still_reapplies_active_profile(self, mock_persist):
|
||||
"""Dropping the wipe must not disturb profile re-application."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
manager.activate_profile("armed")
|
||||
|
||||
new_config = FrigateConfig(**self.config_data)
|
||||
manager.update_config(new_config)
|
||||
|
||||
self.assertEqual(manager.config, new_config)
|
||||
self.assertEqual(new_config.active_profile, "armed")
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase):
|
||||
self.store.clear_all()
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
def test_clear_camera_removes_only_that_camera(self) -> None:
|
||||
self.store.set("front_door", "enabled", False)
|
||||
self.store.set("front_door", "detect", False)
|
||||
self.store.set("back_yard", "audio", False)
|
||||
|
||||
self.store.clear_camera("front_door")
|
||||
|
||||
self.assertEqual(self.store.load(), {"back_yard": {"audio": False}})
|
||||
|
||||
def test_clear_camera_is_noop_for_unknown_camera(self) -> None:
|
||||
self.store.set("front_door", "enabled", False)
|
||||
self.store.clear_camera("side_gate")
|
||||
self.assertEqual(self.store.load(), {"front_door": {"enabled": False}})
|
||||
|
||||
def test_clear_camera_is_safe_when_file_missing(self) -> None:
|
||||
# No prior set() calls, so the file does not exist
|
||||
self.store.clear_camera("front_door")
|
||||
self.assertEqual(self.store.load(), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_notification_test(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Admin access ---
|
||||
|
||||
def test_admin_can_send_restart(self):
|
||||
@@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_admin_can_send_notification_test(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"notification_test", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
|
||||
@@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# check for config updates
|
||||
updated_topics = self.camera_config_subscriber.check_for_updates()
|
||||
|
||||
if "enabled" in updated_topics:
|
||||
for camera in updated_topics["enabled"]:
|
||||
if self.camera_states[camera].prev_enabled is None:
|
||||
self.camera_states[camera].prev_enabled = self.config.cameras[
|
||||
camera
|
||||
].enabled
|
||||
elif "add" in updated_topics:
|
||||
for camera in updated_topics["add"]:
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
# a single drain can carry several topics at once, so add and
|
||||
# remove are handled independently rather than as exclusive branches
|
||||
for camera in updated_topics.get("add", []):
|
||||
self.config.cameras[camera] = (
|
||||
self.camera_config_subscriber.camera_configs[camera]
|
||||
)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
if "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
+164
-24
@@ -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/<pid>/fdinfo/<fd> (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/<pid>/fdinfo/<fd>. 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]] = {}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Semantic Search settings tests -- MEDIUM tier.
|
||||
*
|
||||
* Focuses on the model_size field, which is unused when a GenAI embeddings
|
||||
* provider is selected as the semantic search model. The resolved config always
|
||||
* reports model_size (it has a schema default of "small"), even when the YAML
|
||||
* file has no such key. Clearing model_size for a provider used to run
|
||||
* unconditionally, which falsely marked the section dirty on load and asked the
|
||||
* backend to delete a key that wasn't in the config file (KeyError: 'model_size').
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const PROVIDER = "llama_cpp";
|
||||
const SETTINGS_URL = "/settings?page=integrationSemanticSearch";
|
||||
const NOT_APPLICABLE = "Not applicable for GenAI providers";
|
||||
const UNSAVED = "You have unsaved changes";
|
||||
|
||||
type SemanticSearch = {
|
||||
enabled?: boolean;
|
||||
model?: string;
|
||||
model_size?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, semanticSearch: SemanticSearch) {
|
||||
const config = configFactory({
|
||||
genai: { [PROVIDER]: { provider: PROVIDER, roles: ["embeddings"] } },
|
||||
semantic_search: semanticSearch,
|
||||
});
|
||||
|
||||
let lastSavedConfig: unknown = null;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSavedConfig = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { semantic_search: semanticSearch } }),
|
||||
);
|
||||
|
||||
return { capturedConfig: () => lastSavedConfig };
|
||||
}
|
||||
|
||||
test.describe("semantic search model_size @medium", () => {
|
||||
test("a provider with a defaulted model_size is not dirty on load", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// model_size stays at its schema default ("small"), i.e. it is not present
|
||||
// in the YAML. This mirrors the reported bug: selecting a GenAI provider and
|
||||
// returning to the page.
|
||||
await installRoutes(frigateApp.page, {
|
||||
enabled: true,
|
||||
model: PROVIDER,
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
// The provider path is active: model_size shows "Not applicable".
|
||||
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
|
||||
|
||||
// Give any clearing effect time to fire, then confirm the section stayed
|
||||
// clean (no phantom unsaved-changes banner, Save disabled).
|
||||
await frigateApp.page.waitForTimeout(1000);
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Save", exact: true }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("switching from a configured non-default model_size clears it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// A genuinely configured non-default model_size ("large") can only come from
|
||||
// the YAML, so switching to a provider must still remove it.
|
||||
const capture = await installRoutes(frigateApp.page, {
|
||||
enabled: true,
|
||||
model: "jinav2",
|
||||
model_size: "large",
|
||||
});
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
// Starts clean on a Jina model.
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||
|
||||
// Switch the model to the GenAI provider.
|
||||
await frigateApp.page
|
||||
.getByRole("combobox", { name: /Semantic search model/ })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("option", { name: PROVIDER }).click();
|
||||
|
||||
// The change is now dirty and model_size is no longer applicable.
|
||||
await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Save", exact: true })
|
||||
.click();
|
||||
|
||||
// The saved payload removes model_size (empty string = "remove" key).
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
semantic_search: { model: PROVIDER, model_size: "" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1936,7 +1936,8 @@
|
||||
"inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware."
|
||||
"hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware.",
|
||||
"inputsMissingGo2rtcStream": "An input below points at a go2rtc restream that no longer exists. Select an existing restream or enter the camera's URL manually, otherwise this camera will fail to connect."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseRestreamStreamName } from "../theme/fields/streamSource";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const arrayAsTextWidget = {
|
||||
@@ -42,6 +43,29 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "inputs-missing-go2rtc-stream",
|
||||
field: "inputs",
|
||||
position: "before",
|
||||
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
|
||||
severity: "warning",
|
||||
docLink: "/configuration/restream",
|
||||
condition: (ctx) => {
|
||||
const streams = ctx.fullConfig?.go2rtc?.streams;
|
||||
const inputs = ctx.formData?.inputs;
|
||||
if (!Array.isArray(inputs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return inputs.some((input) => {
|
||||
const path = (input as { path?: unknown } | null)?.path;
|
||||
const streamName = parseRestreamStreamName(
|
||||
typeof path === "string" ? path : undefined,
|
||||
);
|
||||
return streamName !== undefined && !(streamName in (streams ?? {}));
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -170,6 +170,12 @@ export function CameraInputsField(props: FieldProps) {
|
||||
[go2rtcStreamNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSourceModeByIndex((previous) =>
|
||||
Object.keys(previous).length > 0 ? {} : previous,
|
||||
);
|
||||
}, [formContext?.cameraName]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
@@ -222,18 +228,12 @@ export function CameraInputsField(props: FieldProps) {
|
||||
const handleSourceModeChange = useCallback(
|
||||
(index: number, nextMode: StreamSourceMode) => {
|
||||
const input = inputs[index];
|
||||
const currentPath =
|
||||
typeof input?.path === "string" ? input.path : undefined;
|
||||
|
||||
if (nextMode === "manual") {
|
||||
// Only revert the preset we set ourselves; never clobber custom args.
|
||||
if (input?.input_args === RESTREAM_PRESET) {
|
||||
handleFieldValuesChange(index, { input_args: undefined });
|
||||
}
|
||||
} else if (!parseRestreamStreamName(currentPath)) {
|
||||
// Entering restream with a non-restream path: clear it so the dropdown
|
||||
// shows its placeholder until a stream is chosen.
|
||||
handleFieldValuesChange(index, { path: undefined });
|
||||
// Only revert the preset we set ourselves; never clobber custom args.
|
||||
// The path is left alone until a stream is picked, so switching modes
|
||||
// never discards a typed URL or empties a required field.
|
||||
if (nextMode === "manual" && input?.input_args === RESTREAM_PRESET) {
|
||||
handleFieldValuesChange(index, { input_args: undefined });
|
||||
}
|
||||
|
||||
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
|
||||
|
||||
@@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
name: providerKey,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
@@ -227,16 +228,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
"min-w-0 justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
<span className="truncate">
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -263,12 +266,14 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
<Plus className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
@@ -287,11 +292,11 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
<span className="truncate">{model}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) {
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2";
|
||||
|
||||
// Clear model_size while on a provider (buildOverrides converts to ""
|
||||
// which the backend treats as "remove"). Restore the schema default
|
||||
// when returning to a Jina model so the field isn't left empty.
|
||||
// model_size is unused on a GenAI provider. Only clear it (which the backend
|
||||
// treats as "remove") for a non-default value, which can only come from the
|
||||
// config file. A defaulted value is indistinguishable from unset in the
|
||||
// resolved config, so clearing it would falsely dirty the field and delete a
|
||||
// YAML key that isn't there. Restore the default when returning to a Jina model.
|
||||
const { value, onChange, schema } = props;
|
||||
const schemaDefault = schema?.default as string | undefined;
|
||||
useEffect(() => {
|
||||
if (isProvider && value !== undefined) {
|
||||
onChange(undefined);
|
||||
} else if (!isProvider && value === undefined && schemaDefault) {
|
||||
if (isProvider) {
|
||||
if (value !== undefined && value !== schemaDefault) {
|
||||
onChange(undefined);
|
||||
}
|
||||
} else if (value === undefined && schemaDefault) {
|
||||
onChange(schemaDefault);
|
||||
}
|
||||
}, [isProvider, value, onChange, schemaDefault]);
|
||||
|
||||
@@ -129,7 +129,7 @@ export function GeneralFilterContent({
|
||||
className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor={item}
|
||||
>
|
||||
{item.replaceAll("_", " ")}
|
||||
{t(`logger.logLevel.${item}`, { ns: "views/settings" })}
|
||||
</Label>
|
||||
<Switch
|
||||
key={item}
|
||||
|
||||
@@ -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 })}</>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LogSeverity } from "@/types/log";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ChipProps = {
|
||||
className?: string;
|
||||
@@ -50,6 +51,7 @@ type LogChipProps = {
|
||||
onClickSeverity?: () => void;
|
||||
};
|
||||
export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const severityClassName = useMemo(() => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
@@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{severity}
|
||||
{t(`logger.logLevel.${severity}`, { ns: "views/settings" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
@@ -443,10 +444,10 @@ export function ExportContent({
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: latestTime - 3600,
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
}, [activeTab, latestTime, range, setRange]);
|
||||
}, [activeTab, currentTime, range, setRange]);
|
||||
|
||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||
activeTab === "multi" && debouncedRange
|
||||
@@ -816,7 +817,19 @@ export function ExportContent({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ExportTab)}
|
||||
onValueChange={(value) => {
|
||||
const tab = value as ExportTab;
|
||||
if (tab === "multi") {
|
||||
setRange({
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
} else {
|
||||
onSelectTime(selectedOption);
|
||||
}
|
||||
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
@@ -1066,7 +1079,11 @@ export function ExportContent({
|
||||
}
|
||||
>
|
||||
<IconRenderer
|
||||
icon={LuIcons[group.icon]}
|
||||
icon={
|
||||
isValidIconName(group.icon)
|
||||
? LuIcons[group.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="mr-2 size-4 text-secondary-foreground"
|
||||
/>
|
||||
<span className="truncate">{group.name}</span>
|
||||
|
||||
@@ -60,7 +60,10 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn(
|
||||
"scrollbar-container max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -770,6 +770,34 @@ export default function MotionSearchView({
|
||||
};
|
||||
}, [cancelMotionSearchJobViaBeacon]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}, [navigate, onBack]);
|
||||
|
||||
// Dismissing the entry dialog (escape / click outside) before a search has
|
||||
// run leaves nothing behind it, so cancel the flow instead of revealing an
|
||||
// empty page.
|
||||
const handleSearchDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (
|
||||
!nextOpen &&
|
||||
!isSearching &&
|
||||
!hasSearched &&
|
||||
searchResults.length === 0
|
||||
) {
|
||||
handleBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchDialogOpen(nextOpen);
|
||||
},
|
||||
[handleBack, hasSearched, isSearching, searchResults.length],
|
||||
);
|
||||
|
||||
const handleNewSearch = useCallback(() => {
|
||||
if (jobId && jobCamera) {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
@@ -1238,7 +1266,7 @@ export default function MotionSearchView({
|
||||
<Toaster closeButton={true} position="top-center" />
|
||||
<MotionSearchDialog
|
||||
open={isSearchDialogOpen}
|
||||
onOpenChange={setIsSearchDialogOpen}
|
||||
onOpenChange={handleSearchDialogOpenChange}
|
||||
config={config}
|
||||
cameras={cameras}
|
||||
selectedCamera={selectedCamera}
|
||||
@@ -1276,7 +1304,7 @@ export default function MotionSearchView({
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => (onBack ? onBack() : navigate(-1))}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
<div className="mb-5 flex flex-row items-center justify-between">
|
||||
<div className="mb-5">
|
||||
{t("general.hardwareInfo.gpuUsage")}
|
||||
{showIntelGpuWarning && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex flex-row items-center gap-1.5 text-yellow-600 focus:outline-none dark:text-yellow-500"
|
||||
aria-label={t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
>
|
||||
<CiCircleAlert
|
||||
className="size-5"
|
||||
aria-label={t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.message",
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.description",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{gpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
|
||||
Reference in New Issue
Block a user