Compare commits

..
Author SHA1 Message Date
Nicolas Mowen 1a3abd472a Pull from event DB directly 2026-11-04 10:54:50 -07:00
Nicolas Mowen 9fe12ed0b7 Fix reading labels when there are multiple that match 2026-11-03 09:17:26 -07:00
93 changed files with 169 additions and 6040 deletions
-2
View File
@@ -124,7 +124,5 @@ jobs:
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
- name: Check API spec is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
- name: Check analytics schema is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
- name: Run unit tests in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
-1
View File
@@ -383,7 +383,6 @@ FROM deps AS frigate
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=standard
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
# source tree on every boot (pip-installed packages are already compiled)
-1
View File
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rk
COPY docker/rockchip/COCO /COCO
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
-1
View File
@@ -37,7 +37,6 @@ RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.s
WORKDIR /opt/frigate
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rocm
#######################################################################
FROM deps-prelim AS rocm-prelim-hsa-override0
-1
View File
@@ -15,4 +15,3 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=rpi
-1
View File
@@ -22,7 +22,6 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=synaptics
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
-1
View File
@@ -25,7 +25,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
&& pip3 install -U /deps/trt-wheels/*.whl
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=tensorrt
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
RUN ldconfig
-1
View File
@@ -151,7 +151,6 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
WORKDIR /opt/frigate/
COPY --from=rootfs / /
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
@@ -1,41 +0,0 @@
---
id: analytics
title: Anonymous Analytics
---
import AnalyticsFields from "@site/src/components/AnalyticsFields";
import NavPath from "@site/src/components/NavPath";
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
## Turning it on
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
```yaml
telemetry:
analytics: true
```
The same page has a **Preview the report** button that shows exactly what the next report contains.
## How it's sent
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
- The server looks up your country and region from your IP address and never stores the address
- Raw reports are kept for 60 days; only aggregate totals are published
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
## What's never sent
- Camera, zone, group, profile, or user names
- Object labels, face names, or license plate text
- IP addresses, hostnames, URLs, or stream paths
- Credentials or API keys
- Events, recordings, or anything from them
## Every field
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
<AnalyticsFields />
@@ -1218,9 +1218,6 @@ ui:
# Optional: Telemetry configuration
telemetry:
# Optional: Share one anonymous usage report a day (default: shown below)
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
analytics: False
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
network_interfaces:
- eth
+2 -8
View File
@@ -344,7 +344,7 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
- Set **Provider** to `gemini`
- Set **API key** to your Gemini API key (or use an environment variable such as `{FRIGATE_GEMINI_API_KEY}`)
- Set **Model** to the desired model (e.g., `gemini-3.5-flash-lite`)
- Set **Model** to the desired model (e.g., `gemini-2.5-flash`)
</TabItem>
<TabItem value="yaml">
@@ -354,18 +354,12 @@ genai:
my_provider:
provider: gemini
api_key: "{FRIGATE_GEMINI_API_KEY}"
model: gemini-3.5-flash-lite
model: gemini-2.5-flash
```
</TabItem>
</ConfigTabs>
:::tip
Gemini 3.x models charge a (roughly) flat ~1,100 tokens per image regardless of resolution, so a 180p preview frame costs the same as a 480p recording frame. Set the review [image source](/configuration/genai/genai_review#image-source) to `recordings` to send higher quality frames at no extra cost, as long as recordings are enabled for the camera.
:::
:::note
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
+1 -10
View File
@@ -135,15 +135,6 @@ telemetry:
version_check: false
```
### Anonymous Analytics
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
```yaml
telemetry:
analytics: true
```
### Push Notifications
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
@@ -180,7 +171,7 @@ To run Frigate in an air-gapped or offline environment:
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default).
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
After these steps, Frigate will operate with no outbound internet connections.
-1
View File
@@ -130,7 +130,6 @@ const sidebars: SidebarsConfig = {
label: "Advanced Configuration",
items: [
"configuration/advanced/system",
"configuration/advanced/analytics",
"configuration/advanced/reference",
{
type: "link",
@@ -1,60 +0,0 @@
import React from "react";
import schema from "@site/static/frigate-analytics-schema.json";
function resolve(node) {
if (!node) return node;
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
if (node.anyOf) {
const inner = node.anyOf.find((option) => option.type !== "null");
return inner ? resolve(inner) : node;
}
return node;
}
function rows(properties, prefix = "") {
return Object.entries(properties).flatMap(([name, field]) => {
const path = prefix ? `${prefix}.${name}` : name;
const row = {
path,
description: field.description,
isPublic: field["x-public"],
};
const target = resolve(field);
if (target?.properties) return [row, ...rows(target.properties, path)];
const values = resolve(target?.additionalProperties);
if (values?.properties)
return [row, ...rows(values.properties, `${path}.<key>`)];
const items = resolve(target?.items);
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
return [row];
});
}
export default function AnalyticsFields() {
return (
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Public</th>
</tr>
</thead>
<tbody>
{rows(schema.properties).map((row) => (
<tr key={row.path}>
<td>
<code>{row.path}</code>
</td>
<td>{row.description}</td>
<td>{row.isPublic ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
);
}
File diff suppressed because it is too large Load Diff
-19
View File
@@ -10,25 +10,6 @@ servers:
- url: https://demo.frigate.video/api
- url: http://localhost:5001/api
paths:
/analytics/preview:
get:
tags:
- Analytics
summary: Get Analytics Preview
description: |-
**Access:** Admin role required.
Get the analytics report Frigate would send next, without sending it.
operationId: get_analytics_preview_analytics_preview_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
security:
- frigateAdminAuth: []
x-required-role: admin
/auth/first_time_login:
get:
tags:
-1
View File
@@ -1 +0,0 @@
"""Opt-in anonymous analytics reports."""
-1
View File
@@ -1 +0,0 @@
"""One collector per report section."""
-238
View File
@@ -1,238 +0,0 @@
"""Cameras section: counts and histograms across cameras, never per camera."""
from collections import Counter
from typing import Any
from urllib.parse import urlsplit
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
CamerasSection,
ConnectionQuality,
FpsBucket,
HeightBucket,
HwaccelKey,
InputPresetKey,
RetainBucket,
RetainDays,
)
from frigate.config import CameraConfig
from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
from frigate.const import REPLAY_CAMERA_PREFIX
# the upper edge of every height bucket but the last
HEIGHT_BUCKETS = (
(360, HeightBucket.le_360),
(540, HeightBucket.h480),
(900, HeightBucket.h720),
(1260, HeightBucket.h1080),
(1800, HeightBucket.h1440),
)
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
RESTREAM_PORT = 8554
QUALITIES = frozenset(ConnectionQuality)
def height_bucket(height: int) -> HeightBucket:
"""The nearest standard resolution bucket for a detect height."""
for limit, bucket in HEIGHT_BUCKETS:
if height <= limit:
return bucket
return HeightBucket.ge_2160
def fps_bucket(fps: int) -> FpsBucket:
"""The bucket for a detect fps."""
if fps <= 5:
return FpsBucket.le_5
if fps <= 10:
return FpsBucket.f6_10
return FpsBucket.gt_10
def retain_bucket(days: float) -> RetainBucket:
"""The bucket for a retention period in days."""
if days <= 0:
return RetainBucket.zero
if days <= 7:
return RetainBucket.d1_7
if days <= 30:
return RetainBucket.d8_30
return RetainBucket.gt_30
def preset_key(args: str | list[str], enum: Any) -> Any:
"""The preset's name, custom for hand written args, or none."""
if not args:
return enum("none")
if isinstance(args, str) and args.startswith("preset-"):
return closed(enum, args.removeprefix("preset-"), enum("custom"))
return enum("custom")
def is_restream(path: str) -> bool:
"""Whether an input reads from the local go2rtc restream."""
try:
url = urlsplit(path)
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
except ValueError:
return False
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
"""The camera's first input with the given role."""
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
def object_masks(camera: CameraConfig) -> int:
"""Camera and per-label object masks, minus the global_<id> filter copies."""
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
per_label = sum(
1
for label_filter in camera.objects.filters.values()
for mask_id, mask in label_filter.mask.items()
if mask is not None and not mask_id.startswith("global_")
)
return own + per_label
def collect(ctx: ReportContext) -> CamerasSection:
"""Count camera settings across the cameras enabled in config."""
cameras = {
name: camera
for name, camera in ctx.config.cameras.items()
if not name.startswith(REPLAY_CAMERA_PREFIX)
}
camera_stats = ctx.stats.get("cameras", {})
# cameras disabled in config are parked, so they'd skew feature adoption
active = [
(name, camera) for name, camera in cameras.items() if camera.enabled_in_config
]
flags: Counter[str] = Counter()
types: Counter[CameraTypeEnum] = Counter()
heights: Counter[HeightBucket] = Counter()
fps: Counter[FpsBucket] = Counter()
hwaccel: Counter[Any] = Counter()
input_presets: Counter[Any] = Counter()
quality: Counter[ConnectionQuality] = Counter()
retain: dict[str, Counter[RetainBucket]] = {
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
}
for name, camera in active:
detect_input = role_input(camera, CameraRoleEnum.detect)
record_input = role_input(camera, CameraRoleEnum.record)
# config validation requires a detect input, so this never skips
if detect_input is None:
continue
types[camera.type] += 1
fps[fps_bucket(camera.detect.fps)] += 1
hwaccel[
preset_key(
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
)
] += 1
input_presets[
preset_key(
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
)
] += 1
if camera.detect.height:
heights[height_bucket(camera.detect.height)] += 1
state = camera_stats.get(name, {}).get("connection_quality")
# a camera turned off at runtime reports unusable
if camera.enabled and state in QUALITIES:
quality[ConnectionQuality(state)] += 1
if camera.record.enabled:
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
retain["detections"][
retain_bucket(camera.record.detections.retain.days)
] += 1
zones = len(camera.zones)
flags["go2rtc_restream"] += any(
is_restream(i.path) for i in camera.ffmpeg.inputs
)
flags["separate_detect_stream"] += (
record_input is not None and record_input.path != detect_input.path
)
flags["detect"] += camera.detect.enabled
flags["record"] += camera.record.enabled
flags["sub_stream_record"] += (
camera.record.enabled and camera.record.sub.enabled
)
flags["snapshots"] += camera.snapshots.enabled
flags["audio"] += camera.audio.enabled
flags["audio_transcription"] += (
camera.audio.enabled and camera.audio_transcription.enabled
)
flags["birdseye"] += camera.birdseye.enabled
flags["onvif"] += bool(camera.onvif.host)
flags["autotracking"] += camera.onvif.autotracking.enabled
flags["face_recognition"] += camera.face_recognition.enabled
flags["lpr"] += camera.lpr.enabled
flags["review_genai"] += camera.review.genai.enabled
flags["object_genai"] += camera.objects.genai.enabled
flags["notifications"] += camera.notifications.enabled
flags["zones"] += zones
flags["cameras_with_zones"] += zones > 0
flags["motion_masks"] += sum(
1 for mask in camera.motion.mask.values() if mask is not None
)
flags["object_masks"] += object_masks(camera)
return CamerasSection(
total=len(cameras),
enabled=len(active),
types=histogram(types),
detect_height=histogram(heights),
detect_fps=histogram(fps),
hwaccel=histogram(hwaccel),
input_preset=histogram(input_presets),
go2rtc_restream=flags["go2rtc_restream"],
separate_detect_stream=flags["separate_detect_stream"],
detect=flags["detect"],
record=flags["record"],
sub_stream_record=flags["sub_stream_record"],
snapshots=flags["snapshots"],
audio=flags["audio"],
audio_transcription=flags["audio_transcription"],
birdseye=flags["birdseye"],
onvif=flags["onvif"],
autotracking=flags["autotracking"],
face_recognition=flags["face_recognition"],
lpr=flags["lpr"],
review_genai=flags["review_genai"],
object_genai=flags["object_genai"],
notifications=flags["notifications"],
zones=flags["zones"],
cameras_with_zones=flags["cameras_with_zones"],
motion_masks=flags["motion_masks"],
object_masks=flags["object_masks"],
connection_quality=histogram(quality),
retain_days=RetainDays(
continuous=histogram(retain["continuous"]),
motion=histogram(retain["motion"]),
alerts=histogram(retain["alerts"]),
detections=histogram(retain["detections"]),
),
)
-37
View File
@@ -1,37 +0,0 @@
"""Helpers the section collectors share."""
import math
from collections import Counter
from enum import Enum
from typing import Any, TypeVar
E = TypeVar("E", bound=Enum)
def closed(enum: type[E], value: Any, fallback: E) -> E:
"""The member for a value, or the fallback for one the enum doesn't know."""
try:
return enum(str(value))
except ValueError:
return fallback
def rate(value: Any) -> float:
"""A finite, non-negative number rounded to 2 decimals, else 0.
A NaN would serialize as null and fail the schema's number type.
"""
try:
number = float(value)
except (TypeError, ValueError):
return 0.0
if not math.isfinite(number):
return 0.0
return round(max(number, 0.0), 2)
def histogram(counter: "Counter[E]") -> dict[E, int]:
"""Drop the empty buckets, since an absent key means zero."""
return {key: total for key, total in counter.items() if total > 0}
-69
View File
@@ -1,69 +0,0 @@
"""Detection section: models, the detectors they run on, and inference speed."""
import os
from frigate.analytics.collectors.common import rate
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
from frigate.config.config import DEFAULT_MODEL
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detector_config import DEFAULT_SCENE
from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.detectors.device import runner_names
# the paths FrigateConfig fills in for a model that sets none
BUNDLED_MODEL_PATHS = frozenset(
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
)
def model_source(path: str | None) -> ModelSource:
"""Default, Frigate+ (a cached model next to its info file), or custom.
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
"""
if path is None or path in BUNDLED_MODEL_PATHS:
return ModelSource.default
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
return ModelSource.plus
return ModelSource.custom
def collect(ctx: ReportContext) -> DetectionSection:
"""Describe each configured model and its measured inference speed."""
config = ctx.config
detectors = ctx.stats.get("detectors", {})
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
# FrigateApp.start_detectors names the processes in this same order
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
models: list[DetectionModel] = []
for model, specs in model_specs:
speeds: list[float] = []
for _ in specs:
speed = detectors.get(next(names), {}).get("inference_speed")
if isinstance(speed, int | float) and speed > 0:
speeds.append(float(speed))
models.append(
DetectionModel(
default=model.scene == DEFAULT_SCENE,
detector=DetectorTypeEnum(specs[0].detector),
devices=len(specs),
model_type=model.model_type,
input=f"{model.width}x{model.height}",
source=model_source(model.path),
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
)
)
return DetectionSection(
models=models,
detection_fps=rate(ctx.stats.get("detection_fps")),
skipped_fps=rate(ctx.stats.get("skipped_fps")),
)
-150
View File
@@ -1,150 +0,0 @@
"""Features section: enrichments, GenAI, integrations, and users."""
from collections import Counter
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
BirdseyeUsage,
ClassificationUsage,
EnrichmentDevice,
EnrichmentUsage,
FeaturesSection,
GenAIUsage,
SemanticSearchModel,
SemanticSearchUsage,
TranscriptionModel,
TranscriptionUsage,
UserRole,
)
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import User
RUNTIME_DEVICES = {
"cpu": EnrichmentDevice.cpu,
"cuda": EnrichmentDevice.cuda,
"tensorrt": EnrichmentDevice.tensorrt,
"migraphx": EnrichmentDevice.migraphx,
}
OPENVINO_DEVICES = {
"cpu": EnrichmentDevice.openvino_cpu,
"gpu": EnrichmentDevice.openvino_gpu,
"npu": EnrichmentDevice.openvino_npu,
}
def enrichment_device(label: object) -> EnrichmentDevice | None:
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
if not isinstance(label, str) or not label:
return None
runtime, _, target = label.partition(" ")
if runtime == "OpenVINO":
# AUTO reports "(CPU)" while it warms up on the CPU
first = target.split(",")[0].split(".")[0].strip("() ").lower()
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
def model_name(model: object) -> str | None:
"""The model's config value as a string."""
if model is None:
return None
return str(getattr(model, "value", model))
def semantic_model(model: object) -> SemanticSearchModel | None:
"""A built-in Jina model, or genai for any GenAI provider name."""
name = model_name(model)
if name is None:
return None
if name in ("jinav1", "jinav2"):
return SemanticSearchModel(name)
return SemanticSearchModel.genai
def transcription_model(model: object) -> TranscriptionModel | None:
"""Whisper, or genai for any GenAI provider name."""
name = model_name(model)
if name is None:
return None
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
def users() -> dict[UserRole, int]:
"""Count users by role, with any custom role counted as custom."""
roles: Counter[UserRole] = Counter(
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
)
return histogram(roles)
def collect(ctx: ReportContext) -> FeaturesSection:
"""Report which enrichments and integrations are configured."""
config = ctx.config
devices = ctx.stats.get("embeddings", {}).get("devices", {})
cameras = [
camera
for name, camera in config.cameras.items()
if not name.startswith(REPLAY_CAMERA_PREFIX)
]
providers: Counter[GenAIProviderEnum] = Counter(
genai.provider for genai in config.genai.values()
)
roles: Counter[GenAIRoleEnum] = Counter(
role for genai in config.genai.values() for role in genai.roles
)
custom = list(config.classification.custom.values())
return FeaturesSection(
face_recognition=EnrichmentUsage(
enabled=config.face_recognition.enabled,
model_size=config.face_recognition.model_size,
device=enrichment_device(devices.get("face_recognition")),
),
lpr=EnrichmentUsage(
enabled=config.lpr.enabled,
model_size=config.lpr.model_size,
device=enrichment_device(devices.get("lpr")),
),
semantic_search=SemanticSearchUsage(
enabled=config.semantic_search.enabled,
model=semantic_model(config.semantic_search.model),
model_size=config.semantic_search.model_size,
device=enrichment_device(devices.get("semantic_search")),
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
),
audio_transcription=TranscriptionUsage(
enabled=config.audio_transcription.enabled,
model=transcription_model(config.audio_transcription.model),
model_size=config.audio_transcription.model_size,
),
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
classification_models=ClassificationUsage(
state=sum(1 for model in custom if model.state_config is not None),
object=sum(1 for model in custom if model.object_config is not None),
),
birdseye=BirdseyeUsage(
enabled=config.birdseye.enabled,
modes=list(config.birdseye.modes),
restream=config.birdseye.restream,
),
mqtt=config.mqtt.enabled,
notifications=config.notifications.enabled,
auth=config.auth.enabled,
proxy_auth=config.proxy.header_map.user is not None,
tls=config.tls.enabled,
users=users(),
camera_groups=len(config.camera_groups),
profiles=len(config.profiles),
plus_api_key=config.plus_api.is_active(),
)
-114
View File
@@ -1,114 +0,0 @@
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
import os
from collections import Counter
from typing import Any
import psutil
from frigate.analytics.collectors.common import closed, histogram
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
DecodeFamily,
GpuInfo,
GpuVendor,
HardwareKey,
HardwareSection,
StorageInfo,
)
from frigate.const import RECORD_DIR
from frigate.detectors.hardware import hardware_prober
from frigate.util.hwaccel import hwaccel_options
DEVICE_TREE_MODEL = "/proc/device-tree/model"
CPUINFO = "/proc/cpuinfo"
def cpu_model() -> str:
"""The board model on ARM boards, else the CPU's model name."""
try:
with open(DEVICE_TREE_MODEL) as f:
board = f.read().strip("\x00\n ")
if board:
return board[:64]
except OSError:
pass
try:
with open(CPUINFO) as f:
for line in f:
key, _, value = line.partition(":")
if key.strip() == "model name" and value.strip():
return value.strip()[:64]
except OSError:
pass
return "unknown"
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
"""GPUs with a stats entry, by vendor and capped name."""
found: list[GpuInfo] = []
for name, entry in stats.get("gpu_usages", {}).items():
vendor = entry.get("vendor") if isinstance(entry, dict) else None
found.append(
GpuInfo(
vendor=closed(GpuVendor, vendor, GpuVendor.other),
name=str(name)[:64],
)
)
return found
def decode_families() -> list[DecodeFamily]:
"""Hardware decode families the host supports."""
_, available = hwaccel_options()
families = [
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
]
return list(dict.fromkeys(families))
def detection_hardware() -> dict[HardwareKey, int]:
"""Detection accelerators found by the hardware probe, by unit count."""
units: Counter[HardwareKey] = Counter()
for found in hardware_prober.probe():
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
return histogram(units)
def storage(stats: dict[str, Any]) -> StorageInfo:
"""Filesystem, size, and usage of the recordings volume.
Stats report sizes in MB.
"""
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
total_mb = float(entry.get("total") or 0)
used_mb = float(entry.get("used") or 0)
return StorageInfo(
record_fs=str(entry.get("mount_type") or "unknown")[:16],
record_total_gb=round(total_mb / 1024),
record_used_pct=min(round(used_mb / total_mb * 100), 100)
if total_mb > 0
else 0,
)
def collect(ctx: ReportContext) -> HardwareSection:
"""Describe the host: CPU, memory, GPUs, decoders, detectors, storage."""
return HardwareSection(
cpu_model=cpu_model(),
cpu_cores=os.cpu_count() or 0,
memory_gb=round(psutil.virtual_memory().total / 2**30),
gpus=gpus(ctx.stats),
decode_families=decode_families(),
detection_hardware=detection_hardware(),
storage=storage(ctx.stats),
)
-63
View File
@@ -1,63 +0,0 @@
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
from typing import Any
from frigate.analytics.collectors.common import rate
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import (
EnrichmentTiming,
HealthSection,
NoticeCounts,
NoticeKindKey,
)
TIMING_STATS = {
EnrichmentTiming.face: "face_recognition_speed",
EnrichmentTiming.lpr: "plate_recognition_speed",
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
EnrichmentTiming.image_embedding: "image_embedding_speed",
EnrichmentTiming.text_embedding: "text_embedding_speed",
EnrichmentTiming.review_description: "review_description_speed",
EnrichmentTiming.object_description: "object_description_speed",
}
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
def notice_counts(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
"""Counts since the install ID was created, per reportable kind."""
counts: dict[Any, NoticeCounts] = {}
for row in notice_stats:
if row["kind"] not in REPORTABLE_KINDS:
continue
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
acknowledgements = max(
row["acknowledgements"] - row["reported_acknowledgements"], 0
)
mutes = max(row["mutes"] - row["reported_mutes"], 0)
if occurrences or acknowledgements or mutes:
counts[NoticeKindKey(row["kind"])] = NoticeCounts(
occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
)
return counts
def collect(ctx: ReportContext) -> HealthSection:
"""Read health figures from the latest stats and notice counts."""
service = ctx.stats.get("service", {})
embeddings = ctx.stats.get("embeddings", {})
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
timings = {
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
}
return HealthSection(
uptime_hours=int(rate(service.get("uptime")) // 3600),
cpu_percent=min(round(rate(cpu)), 100),
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
retention_unmet=bool(service.get("retention_unmet", False)),
notices=notice_counts(ctx.notice_stats),
)
-66
View File
@@ -1,66 +0,0 @@
"""Install section: version, image variant, install type, platform."""
import os
import platform
import re
from frigate.analytics.collectors.common import closed
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
from frigate.version import VERSION
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
def image_variant(value: str | None) -> ImageVariant:
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
if not value:
return ImageVariant.dev
return closed(ImageVariant, value, ImageVariant.other)
def install_type() -> InstallType:
"""How Frigate runs; the add-on is a container too, so it's checked first."""
if os.path.isfile("/data/options.json"):
return InstallType.ha_addon
if os.environ.get("KUBERNETES_SERVICE_HOST"):
return InstallType.kubernetes
if os.path.exists("/run/.containerenv"):
return InstallType.podman
if os.path.exists("/.dockerenv"):
return InstallType.docker
return InstallType.unknown
def arch(machine: str) -> Arch:
"""The CPU architecture for a platform.machine() value."""
match machine.lower():
case "x86_64" | "amd64":
return Arch.x86_64
case "aarch64" | "arm64":
return Arch.aarch64
case _:
return Arch.other
def kernel(release: str) -> str:
"""The major.minor of a kernel release string, or unknown."""
match = KERNEL_PATTERN.match(release)
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
def collect(ctx: ReportContext) -> InstallSection:
"""Describe the Frigate version, image, install method, and platform."""
return InstallSection(
version=VERSION[:32],
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
install_type=install_type(),
arch=arch(platform.machine()),
kernel=kernel(platform.release()),
run_as_root=os.geteuid() == 0,
)
-13
View File
@@ -1,13 +0,0 @@
"""What the collectors read, gathered once per report."""
from dataclasses import dataclass, field
from typing import Any
from frigate.config import FrigateConfig
@dataclass(frozen=True)
class ReportContext:
config: FrigateConfig
stats: dict[str, Any]
notice_stats: list[dict[str, Any]] = field(default_factory=list)
-91
View File
@@ -1,91 +0,0 @@
"""Build a report from the section collectors."""
import logging
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from pydantic import BaseModel
from frigate.analytics.collectors import (
cameras,
detection,
features,
hardware,
health,
install,
)
from frigate.analytics.context import ReportContext
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
from frigate.analytics.state import load_state
from frigate.config import FrigateConfig
if TYPE_CHECKING:
from frigate.notices.registry import NoticeRegistry
from frigate.stats.emitter import StatsEmitter
logger = logging.getLogger(__name__)
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
"install": install.collect,
"hardware": hardware.collect,
"detection": detection.collect,
"cameras": cameras.collect,
"features": features.collect,
"health": health.collect,
}
# shown in the preview until the first report creates a real ID
PREVIEW_INSTALL_ID = "0" * 32
def build_report(
ctx: ReportContext, install_id: str, sent_at: int | None = None
) -> AnalyticsReport:
"""Run every collector; one that fails sends its section as null."""
sections: dict[str, Any] = {}
for name, collect in COLLECTORS.items():
try:
sections[name] = collect(ctx)
except Exception:
# a collector bug must cost one section, never the whole report
logger.debug("Analytics %s section failed", name, exc_info=True)
sections[name] = None
return AnalyticsReport.model_validate(
{
"schema_version": SCHEMA_VERSION,
"install_id": install_id,
"report_id": str(uuid4()),
"sent_at": int(time.time()) if sent_at is None else sent_at,
**sections,
}
)
def gather_context(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> ReportContext:
"""Snapshot the config, latest stats, and notice counts for one report."""
return ReportContext(
config=config,
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
notice_stats=notice_registry.stats() if notice_registry is not None else [],
)
def preview_report(
config: FrigateConfig,
stats_emitter: "StatsEmitter | None",
notice_registry: "NoticeRegistry | None",
) -> AnalyticsReport:
"""The report the next send would carry, without sending it."""
state = load_state()
# an opt-in resets the baseline, so before one there are no notices to show
ctx = gather_context(config, stats_emitter, notice_registry if state else None)
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
-218
View File
@@ -1,218 +0,0 @@
"""Send one analytics report a day while the admin has opted in."""
import logging
import random
import threading
import time
from collections.abc import Callable
from multiprocessing.synchronize import Event as MpEvent
from typing import TYPE_CHECKING
from pydantic import ValidationError
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from frigate.analytics.report import build_report, gather_context
from frigate.analytics.state import (
STATE_PATH,
AnalyticsState,
delete_state,
load_state,
new_state,
save_state,
)
from frigate.analytics.transport import SendOutcome, send_report
from frigate.config import FrigateConfig
from frigate.config.holder import ConfigHolder
from frigate.config.telemetry import TelemetryConfig
from frigate.const import ANALYTICS_URL
from frigate.notices import raise_notice, resolve_notice
from frigate.util.config import find_config_file
if TYPE_CHECKING:
from frigate.notices.registry import NoticeRegistry
from frigate.stats.emitter import StatsEmitter
logger = logging.getLogger(__name__)
WAKE_S = 10 * 60
INTERVAL_S = 24 * 60 * 60
JITTER_S = 60 * 60
FIRST_DELAY_S = (15 * 60, 45 * 60)
PROMPT_KIND = "analytics_prompt"
def saved_consent(path: str) -> bool | None:
"""telemetry.analytics as saved in the config file, or None if unreadable."""
try:
with open(path) as f:
raw = YAML(typ="safe").load(f)
except (OSError, ValueError, YAMLError):
return None
if not isinstance(raw, dict):
return None
# the live config's model, so yes, on, and 1 read as consent there too
try:
return TelemetryConfig.model_validate(raw.get("telemetry") or {}).analytics
except ValidationError:
return None
class AnalyticsReporter(threading.Thread):
"""Follows the live config, so a settings save needs no restart."""
def __init__(
self,
config_holder: ConfigHolder,
stats_emitter: "StatsEmitter",
notice_registry: "NoticeRegistry",
stop_event: MpEvent | threading.Event,
*,
state_path: str = STATE_PATH,
config_path: str | None = None,
url: str = ANALYTICS_URL,
send: Callable[[str, str], SendOutcome] = send_report,
clock: Callable[[], float] = time.time,
rng: random.Random | None = None,
) -> None:
super().__init__(name="analytics_reporter", daemon=True)
self.config_holder = config_holder
self.stats_emitter = stats_emitter
self.notice_registry = notice_registry
self.stop_event = stop_event
self.state_path = state_path
self.config_path = config_path or find_config_file()
self.url = url
self.send = send
self.clock = clock
self.rng = rng or random.Random()
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
self.interval = self._next_interval()
self.opted_in: bool | None = None
self.warned_unwritable = False
# a settings save runs on an API thread while a wake may be mid-attempt
self._lock = threading.Lock()
config_holder.subscribe(self._on_config)
def _next_interval(self) -> float:
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
def run(self) -> None:
"""Tick every wake until stopped; a failed tick never ends the thread."""
while True:
try:
self.tick()
except Exception:
logger.exception("Analytics reporter failed")
if self.stop_event.wait(WAKE_S):
break
def _on_config(self, config: FrigateConfig) -> None:
# a save can turn sharing off and back on between two wakes, so an
# opt-out is handled when it's saved rather than at the next wake
with self._lock:
if not config.safe_mode:
self._apply_consent(config.telemetry.analytics)
def _apply_consent(self, opted_in: bool) -> None:
# called with the lock held
if opted_in == self.opted_in:
return
self.opted_in = opted_in
if opted_in:
resolve_notice(PROMPT_KIND)
# the opt-in sets the notice baseline, so only notices raised with
# consent are counted; tick() retries a failed write
if load_state(self.state_path) is None:
save_state(self._new_identity(), self.state_path)
else:
raise_notice(PROMPT_KIND)
delete_state(self.state_path)
def _new_identity(self) -> AnalyticsState:
# called with the lock held; the baseline resets before the state file
# names the new ID, so a failed write only means another reset
self.notice_registry.reset_analytics_baseline()
return new_state()
def tick(self) -> None:
"""Apply consent and send a report when one is due."""
with self._lock:
config = self.config_holder.config
# safe mode parses a default config where analytics reads as off,
# and handling that as an opt-out would delete the install ID
if config.safe_mode:
return
self._apply_consent(config.telemetry.analytics)
if not config.telemetry.analytics:
return
now = self.clock()
if now < self.first_due:
return
state = load_state(self.state_path)
if state is not None and not self._due(state.last_attempt_at, now):
return
# a YAML editor save or a hand edit reaches the live config only on
# restart, so an opt-out saved that way is read from the file
if saved_consent(self.config_path) is False:
delete_state(self.state_path)
return
state = AnalyticsState(
(state or self._new_identity()).install_id, last_attempt_at=now
)
# saved before sending, so a failing endpoint or a crash mid-send
# still waits a full interval; without saved state every boot would
# send under a new install ID
if not save_state(state, self.state_path):
if not self.warned_unwritable:
logger.warning(
"Analytics is on, but %s isn't writable, so no report is sent",
self.state_path,
)
self.warned_unwritable = True
return
self.interval = self._next_interval()
# the lock stays free during the request, so a save never waits on it
self._send(state.install_id, now)
def _due(self, last_attempt_at: float, now: float) -> bool:
# a last attempt stamped in the future came from a wrong clock
return (
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
)
def _send(self, install_id: str, now: float) -> None:
ctx = gather_context(
self.config_holder.config, self.stats_emitter, self.notice_registry
)
report = build_report(ctx, install_id, sent_at=int(now))
body = report.model_dump_json()
# consent can be withdrawn while the report builds
if not self.config_holder.config.telemetry.analytics:
return
if self.send(self.url, body) is not SendOutcome.accepted:
return
logger.info("Sent the daily analytics report")
logger.debug("Analytics report: %s", body)
-419
View File
@@ -1,419 +0,0 @@
"""Models for the analytics report, the contract with the ingest endpoint.
Every field carries a description and an x-public flag, and no field accepts
user-entered text, so a report can't carry camera names or other free text.
"""
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from frigate.config.camera.birdseye import BirdseyeModeEnum
from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
from frigate.config.classification import ModelSizeEnum
from frigate.detectors.detector_config import ModelTypeEnum
from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
from frigate.notices.types import NOTICE_KINDS
SCHEMA_VERSION = 1
class ImageVariant(StrEnum):
standard = "standard"
rpi = "rpi"
tensorrt = "tensorrt"
tensorrt_jp6 = "tensorrt-jp6"
rocm = "rocm"
rk = "rk"
synaptics = "synaptics"
dev = "dev"
other = "other"
class InstallType(StrEnum):
ha_addon = "ha_addon"
docker = "docker"
podman = "podman"
kubernetes = "kubernetes"
unknown = "unknown"
class Arch(StrEnum):
x86_64 = "x86_64"
aarch64 = "aarch64"
other = "other"
class GpuVendor(StrEnum):
intel = "intel"
amd = "amd"
nvidia = "nvidia"
rockchip = "rockchip"
rpi = "rpi"
apple_silicon = "apple-silicon"
other = "other"
class DecodeFamily(StrEnum):
nvidia = "nvidia"
vaapi = "vaapi"
rkmpp = "rkmpp"
intel_qsv = "intel-qsv"
jetson = "jetson"
rpi = "rpi"
apple_silicon = "apple-silicon"
other = "other"
class HardwareKey(StrEnum):
edgetpu_pci = "edgetpu:pci"
edgetpu_usb = "edgetpu:usb"
openvino_gpu = "openvino:GPU"
openvino_npu = "openvino:NPU"
onnx_amd = "onnx:amd"
onnx_nvidia = "onnx:nvidia"
onnx_lighter = "onnx:lighter"
tensorrt = "tensorrt"
hailo = "hailo"
memryx = "memryx"
deepx = "deepx"
rknn = "rknn"
axengine = "axengine"
axelera = "axelera"
synaptics = "synaptics"
cpu = "cpu"
other = "other"
class ModelSource(StrEnum):
default = "default"
plus = "plus"
custom = "custom"
class HeightBucket(StrEnum):
le_360 = "le_360"
h480 = "480"
h720 = "720"
h1080 = "1080"
h1440 = "1440"
ge_2160 = "ge_2160"
class FpsBucket(StrEnum):
le_5 = "le_5"
f6_10 = "6_10"
gt_10 = "gt_10"
class RetainBucket(StrEnum):
zero = "0"
d1_7 = "1_7"
d8_30 = "8_30"
gt_30 = "gt_30"
class ConnectionQuality(StrEnum):
excellent = "excellent"
fair = "fair"
poor = "poor"
unusable = "unusable"
class EnrichmentDevice(StrEnum):
cpu = "cpu"
cuda = "cuda"
tensorrt = "tensorrt"
migraphx = "migraphx"
openvino_cpu = "openvino_cpu"
openvino_gpu = "openvino_gpu"
openvino_npu = "openvino_npu"
other = "other"
class SemanticSearchModel(StrEnum):
jinav1 = "jinav1"
jinav2 = "jinav2"
genai = "genai"
class TranscriptionModel(StrEnum):
whisper = "whisper"
genai = "genai"
class UserRole(StrEnum):
admin = "admin"
viewer = "viewer"
custom = "custom"
class EnrichmentTiming(StrEnum):
face = "face"
lpr = "lpr"
plate_detection = "plate_detection"
image_embedding = "image_embedding"
text_embedding = "text_embedding"
review_description = "review_description"
object_description = "object_description"
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
keys = [name.removeprefix("preset-") for name in presets]
return {key: key for key in [*keys, "custom", "none"]}
# built from the registries they mirror, so a new preset or notice kind reaches
# the schema through generate_analytics_schema.py instead of a hand edit
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
NoticeKindKey = StrEnum( # type: ignore[misc]
"NoticeKindKey",
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
)
class AnalyticsModel(BaseModel):
model_config = ConfigDict(extra="forbid")
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
"""Declare a report field; the description and flag land in the schema."""
return Field(
description=description, json_schema_extra={"x-public": public}, **kwargs
)
def count(description: str) -> Any:
"""Declare a non-negative count field."""
return metric(description, ge=0)
class InstallSection(AnalyticsModel):
version: str = metric("Frigate version string", max_length=32)
image_variant: ImageVariant = metric("Published image the install runs")
install_type: InstallType = metric("How Frigate is installed")
arch: Arch = metric("CPU architecture")
kernel: str = metric(
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
)
run_as_root: bool = metric("Whether the main process runs as root")
class GpuInfo(AnalyticsModel):
vendor: GpuVendor = metric("GPU vendor")
name: str = metric("GPU name as the hardware reports it", max_length=64)
class StorageInfo(AnalyticsModel):
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
record_total_gb: int = count("Size of the recordings volume in GB")
record_used_pct: int = metric(
"Percent of the recordings volume in use", ge=0, le=100
)
class HardwareSection(AnalyticsModel):
cpu_model: str = metric(
"CPU or board model as the hardware reports it", max_length=64
)
cpu_cores: int = count("Logical CPU count")
memory_gb: int = count("Total memory in GB")
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
decode_families: list[DecodeFamily] = metric(
"Hardware decode families this system can use"
)
detection_hardware: dict[HardwareKey, int] = metric(
"Detection hardware found, as unit counts by kind"
)
storage: StorageInfo = metric("Recordings storage")
class DetectionModel(AnalyticsModel):
default: bool = metric("Whether the model serves cameras that set no scene")
detector: DetectorTypeEnum = metric("Detector type the model runs on")
devices: int = count("Devices the model runs on")
model_type: ModelTypeEnum = metric("Model architecture")
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
source: ModelSource = metric("Where the model came from", public=False)
inference_ms: float | None = metric(
"Mean inference time across the model's detector processes, null before the first stats",
ge=0,
)
class DetectionSection(AnalyticsModel):
models: list[DetectionModel] = metric("Detection models")
detection_fps: float = metric("Detections per second across cameras", ge=0)
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
class RetainDays(AnalyticsModel):
continuous: dict[RetainBucket, int] = metric(
"Recording cameras by continuous retention in days"
)
motion: dict[RetainBucket, int] = metric(
"Recording cameras by motion retention in days"
)
alerts: dict[RetainBucket, int] = metric(
"Recording cameras by alert retention in days"
)
detections: dict[RetainBucket, int] = metric(
"Recording cameras by detection retention in days"
)
class CamerasSection(AnalyticsModel):
total: int = count("Configured cameras")
enabled: int = count(
"Cameras enabled in config; every count below covers only these"
)
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
detect_height: dict[HeightBucket, int] = metric(
"Cameras by detect resolution height"
)
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
hwaccel: dict[HwaccelKey, int] = metric(
"Cameras by the resolved hwaccel preset of the detect input"
)
input_preset: dict[InputPresetKey, int] = metric(
"Cameras by the input preset of the detect input"
)
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
separate_detect_stream: int = count(
"Cameras whose detect input differs from their record input"
)
detect: int = count("Cameras with detection on")
record: int = count("Cameras with recording on")
sub_stream_record: int = count("Cameras with sub stream recording on")
snapshots: int = count("Cameras with snapshots on")
audio: int = count("Cameras with audio detection on")
audio_transcription: int = count(
"Cameras with audio detection and transcription on"
)
birdseye: int = count("Cameras in birdseye")
onvif: int = count("Cameras with an ONVIF host")
autotracking: int = count("Cameras with PTZ autotracking on")
face_recognition: int = count("Cameras with face recognition on")
lpr: int = count("Cameras with license plate recognition on")
review_genai: int = count("Cameras with GenAI review summaries on")
object_genai: int = count("Cameras with GenAI object descriptions on")
notifications: int = count("Cameras with notifications on")
zones: int = count("Zones across cameras")
cameras_with_zones: int = count("Cameras with at least one zone")
motion_masks: int = count("Motion masks across cameras")
object_masks: int = count("Object masks across cameras")
connection_quality: dict[ConnectionQuality, int] = metric(
"Cameras by connection quality at send time"
)
retain_days: RetainDays = metric("Recording retention")
class EnrichmentUsage(AnalyticsModel):
enabled: bool = metric("Whether the enrichment is on")
model_size: ModelSizeEnum = metric("Configured model size")
device: EnrichmentDevice | None = metric(
"Device the model loaded on, null when it isn't loaded"
)
class SemanticSearchUsage(AnalyticsModel):
enabled: bool = metric("Whether semantic search is on")
model: SemanticSearchModel | None = metric(
"Embedding model, genai for a GenAI provider"
)
model_size: ModelSizeEnum = metric("Configured model size")
device: EnrichmentDevice | None = metric(
"Device the model loaded on, null when it isn't loaded"
)
triggers: int = count("Semantic search triggers across cameras")
class TranscriptionUsage(AnalyticsModel):
enabled: bool = metric("Whether audio transcription is on")
model: TranscriptionModel | None = metric(
"Transcription model, genai for a GenAI provider"
)
model_size: ModelSizeEnum = metric("Configured model size")
class GenAIUsage(AnalyticsModel):
providers: dict[GenAIProviderEnum, int] = metric(
"Configured GenAI providers by type"
)
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
class ClassificationUsage(AnalyticsModel):
state: int = count("Custom state classification models")
object: int = count("Custom object classification models")
class BirdseyeUsage(AnalyticsModel):
enabled: bool = metric("Whether birdseye is on")
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
restream: bool = metric("Whether birdseye is restreamed")
class FeaturesSection(AnalyticsModel):
face_recognition: EnrichmentUsage = metric("Face recognition")
lpr: EnrichmentUsage = metric("License plate recognition")
semantic_search: SemanticSearchUsage = metric("Semantic search")
audio_transcription: TranscriptionUsage = metric("Audio transcription")
genai: GenAIUsage = metric("Generative AI providers")
classification_models: ClassificationUsage = metric("Custom classification models")
birdseye: BirdseyeUsage = metric("Birdseye")
mqtt: bool = metric("Whether MQTT is on")
notifications: bool = metric("Whether web push notifications are on")
auth: bool = metric("Whether authentication is on")
proxy_auth: bool = metric("Whether a proxy supplies the user header")
tls: bool = metric("Whether TLS is on")
users: dict[UserRole, int] = metric("Users by role")
camera_groups: int = count("Camera groups")
profiles: int = count("Profiles")
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
class NoticeCounts(AnalyticsModel):
occurrences: int = count("Occurrences since the install ID was created")
acknowledgements: int = count("Acknowledgements since the install ID was created")
mutes: int = count("Mutes since the install ID was created")
class HealthSection(AnalyticsModel):
uptime_hours: int = count("Hours since Frigate started")
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
enrichment_ms: dict[EnrichmentTiming, float] = metric(
"Mean enrichment inference times in milliseconds"
)
retention_unmet: bool = metric(
"Whether storage can't keep the configured retention"
)
notices: dict[NoticeKindKey, NoticeCounts] = metric(
"Cumulative notice counts by kind since the install ID was created",
public=False,
)
class AnalyticsReport(AnalyticsModel):
schema_version: int = metric("Report format version", ge=1)
install_id: str = metric(
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
)
report_id: str = metric(
"Random identifier of this report",
public=False,
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
)
sent_at: int = count("Unix time the report was built")
install: InstallSection | None = metric("Install, null if its collector failed")
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
detection: DetectionSection | None = metric(
"Object detection, null if its collector failed"
)
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
features: FeaturesSection | None = metric("Features, null if its collector failed")
health: HealthSection | None = metric("Health, null if its collector failed")
-86
View File
@@ -1,86 +0,0 @@
"""The install's analytics identity and last attempt time, kept under /config."""
import json
import logging
import os
import re
from dataclasses import dataclass
from uuid import uuid4
from frigate.const import CONFIG_DIR
logger = logging.getLogger(__name__)
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
@dataclass(frozen=True)
class AnalyticsState:
install_id: str
last_attempt_at: float
def new_state() -> AnalyticsState:
"""A fresh install ID that has never attempted a report."""
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
"""The saved state, or None when the file is missing or unusable."""
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
logger.debug("Ignoring unreadable analytics state at %s", path)
return None
if not isinstance(data, dict):
return None
install_id = data.get("install_id")
last_attempt_at = data.get("last_attempt_at")
if (
not isinstance(install_id, str)
or not INSTALL_ID_PATTERN.fullmatch(install_id)
or isinstance(last_attempt_at, bool)
or not isinstance(last_attempt_at, int | float)
):
logger.debug("Ignoring invalid analytics state at %s", path)
return None
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
"""Write the state atomically, returning False when it can't be written."""
temp_path = f"{path}.tmp"
try:
with open(temp_path, "w") as f:
json.dump(
{
"install_id": state.install_id,
"last_attempt_at": state.last_attempt_at,
},
f,
)
os.replace(temp_path, path)
except OSError:
return False
return True
def delete_state(path: str = STATE_PATH) -> None:
"""Forget the install ID, so a later opt-in starts a fresh identity."""
try:
os.remove(path)
except FileNotFoundError:
pass
except OSError:
logger.warning("Unable to delete analytics state at %s", path)
-54
View File
@@ -1,54 +0,0 @@
"""POST a report to the ingest endpoint."""
import logging
from enum import Enum
import requests
from frigate.version import VERSION
logger = logging.getLogger(__name__)
TIMEOUT_S = 30
class SendOutcome(Enum):
accepted = "accepted"
rejected = "rejected"
rate_limited = "rate_limited"
failed = "failed"
def send_report(url: str, body: str) -> SendOutcome:
"""Send one report. Never raises; the outcome says what happened."""
try:
# a redirect would turn the POST into a GET, so it counts as a failure
response = requests.post(
url,
data=body.encode(),
headers={
"Content-Type": "application/json",
"User-Agent": f"Frigate/{VERSION}",
},
timeout=TIMEOUT_S,
allow_redirects=False,
)
except requests.RequestException as err:
logger.debug("Unable to send the analytics report: %s", err)
return SendOutcome.failed
status = response.status_code
if 200 <= status < 300:
return SendOutcome.accepted
if status == 400:
logger.debug("The analytics report was rejected: %s", response.text[:200])
return SendOutcome.rejected
if status == 429:
logger.debug("The analytics endpoint is rate limiting this install")
return SendOutcome.rate_limited
logger.debug("The analytics endpoint returned %s", status)
return SendOutcome.failed
-25
View File
@@ -1,25 +0,0 @@
"""Analytics APIs."""
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from frigate.analytics.report import preview_report
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.analytics])
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
def get_analytics_preview(request: Request) -> JSONResponse:
"""Get the analytics report Frigate would send next, without sending it."""
report = preview_report(
request.app.frigate_config,
request.app.stats_emitter,
request.app.notice_registry,
)
return JSONResponse(content=report.model_dump(mode="json"))
+1 -3
View File
@@ -1,6 +1,5 @@
"""Chat and LLM tool calling APIs."""
import asyncio
import base64
import json
import logging
@@ -1654,8 +1653,7 @@ async def chat_completion(
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s) in conversation"
)
response = await asyncio.to_thread(
genai_client.chat_with_tools,
response = genai_client.chat_with_tools(
messages=conversation,
tools=llm_tools if llm_tools else None,
tool_choice="auto",
-1
View File
@@ -2,7 +2,6 @@ from enum import Enum
class Tags(Enum):
analytics = "Analytics"
app = "App"
auth = "Auth"
camera = "Camera"
+1 -3
View File
@@ -13,8 +13,8 @@ from slowapi.middleware import SlowAPIMiddleware
from starlette_context import middleware, plugins
from starlette_context.plugins import Plugin
from frigate.api import app as main_app
from frigate.api import (
analytics,
auth,
camera,
chat,
@@ -31,7 +31,6 @@ from frigate.api import (
record,
review,
)
from frigate.api import app as main_app
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.event_metadata_updater import (
@@ -144,7 +143,6 @@ def create_fastapi_app(
# Routes
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
app.include_router(analytics.router)
app.include_router(auth.router)
app.include_router(camera.router)
app.include_router(chat.router)
-13
View File
@@ -15,7 +15,6 @@ import uvicorn
from peewee import SqliteDatabase
from peewee_migrate import Router
from frigate.analytics.reporter import AnalyticsReporter
from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app
from frigate.camera import CameraMetrics, PTZMetrics
@@ -525,15 +524,6 @@ class FrigateApp:
)
self.stats_emitter.start()
def start_analytics_reporter(self) -> None:
self.analytics_reporter = AnalyticsReporter(
self.config_holder,
self.stats_emitter,
self.notice_registry,
self.stop_event,
)
self.analytics_reporter.start()
def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
@@ -707,7 +697,6 @@ class FrigateApp:
self.start_audio_processor()
self.start_storage_maintainer()
self.start_stats_emitter()
self.start_analytics_reporter()
self.start_timeline_processor()
self.start_event_processor()
self.start_event_cleanup()
@@ -809,8 +798,6 @@ class FrigateApp:
self.event_cleanup.join()
self.record_cleanup.join()
self.stats_emitter.join()
# a send in flight can hold the thread for the whole request timeout
self.analytics_reporter.join(timeout=5)
self.frigate_watchdog.join()
self.camera_maintainer.join()
self.db.stop()
+7 -2
View File
@@ -57,6 +57,11 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
return payload or MQTT_NO_MODES
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
"""Return the default Birdseye activity modes."""
return [BirdseyeModeEnum.all_objects]
class BirdseyeLayoutConfig(FrigateBaseModel):
scaling_factor: float = Field(
default=2.0,
@@ -79,7 +84,7 @@ class BirdseyeConfig(FrigateBaseModel):
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default=[BirdseyeModeEnum.all_objects],
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
@@ -134,7 +139,7 @@ class BirdseyeCameraConfig(BaseModel):
description="Enable or disable the Birdseye view feature.",
)
modes: list[BirdseyeModeEnum] = Field(
default=[BirdseyeModeEnum.all_objects],
default_factory=default_birdseye_modes,
title="Activity types",
description="Activity types that include cameras in Birdseye.",
)
-17
View File
@@ -1,14 +1,9 @@
"""Shared handle on the config object that is current for this instance."""
import logging
from collections.abc import Callable
from .config import FrigateConfig
__all__ = ["ConfigHolder"]
logger = logging.getLogger(__name__)
class ConfigHolder:
"""Indirection for the most recently parsed config.
@@ -28,24 +23,12 @@ class ConfigHolder:
def __init__(self, config: FrigateConfig) -> None:
self._config = config
self._listeners: list[Callable[[FrigateConfig], None]] = []
@property
def config(self) -> FrigateConfig:
"""The config as of the most recent successful save."""
return self._config
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
"""Call listener on the saving thread with each config installed later."""
self._listeners.append(listener)
def set(self, config: FrigateConfig) -> None:
"""Install a freshly parsed config as the current one."""
self._config = config
for listener in self._listeners:
try:
listener(config)
except Exception:
# a listener bug must not fail the save that has already applied
logger.exception("Config listener failed")
-5
View File
@@ -29,11 +29,6 @@ class StatsConfig(FrigateBaseModel):
class TelemetryConfig(FrigateBaseModel):
analytics: bool = Field(
default=False,
title="Share anonymous analytics",
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
)
network_interfaces: list[str] = Field(
default=[],
title="Network interfaces",
-3
View File
@@ -102,9 +102,6 @@ MAX_WAL_SIZE = 10 # MB
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
ANALYTICS_URL = os.environ.get(
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
)
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
+10 -18
View File
@@ -7,8 +7,7 @@ overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) with
``azure_endpoint`` instead of ``base_url``. Raises if the URL is
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
malformed; :class:`GenAIClientManager` catches the exception and
disables the provider.
- Context size: Azure does not expose a per-model ``max_model_len`` field
@@ -30,10 +29,9 @@ the ``transcribe`` role:
"""
import logging
from typing import Any
from urllib.parse import parse_qs, urlparse
from openai import AsyncAzureOpenAI, AzureOpenAI
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider
@@ -46,8 +44,8 @@ logger = logging.getLogger(__name__)
class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
def _client_options(self) -> dict[str, Any]:
"""Build the Azure client options from the configured base_url."""
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
@@ -55,19 +53,13 @@ class AzureOpenAIClient(OpenAIClient):
if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.")
return {
"api_key": self.genai_config.api_key,
"api_version": api_version,
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
}
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
def _init_provider(self) -> AzureOpenAI:
"""Initialize the AzureOpenAI client from the configured base_url."""
return AzureOpenAI(**self._client_options())
def _init_async_provider(self) -> AsyncAzureOpenAI:
"""Create an async Azure client for streaming chat."""
return AsyncAzureOpenAI(**self._client_options())
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K."""
-78
View File
@@ -2,17 +2,14 @@
import base64
import binascii
import io
import json
import logging
import time
from collections.abc import AsyncGenerator
from typing import Any
from google import genai
from google.genai import errors, types
from google.genai.types import FunctionCallingConfigMode
from PIL import Image
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
@@ -24,11 +21,6 @@ logger = logging.getLogger(__name__)
# under it so the request fails as a log line rather than a 400.
GEMINI_MAX_INLINE_BYTES = 15 * 1024 * 1024
# Image-token probes run on the enrichment thread, so they get a short timeout,
# no retries, and a cooldown after failure instead of the client's defaults.
COUNT_TOKENS_TIMEOUT_MS = 5000
COUNT_TOKENS_RETRY_INTERVAL = 300
def _decode_thought_signature(value: Any) -> bytes | None:
"""Decode a base64-encoded thought_signature carried across conversation turns."""
@@ -101,14 +93,9 @@ class GeminiClient(GenAIClient):
"""Generative AI client for Frigate using Gemini."""
provider: genai.Client
_image_token_cache: dict[tuple[int, int], int]
_image_token_retry_at: float
def _init_provider(self) -> genai.Client:
"""Initialize the client."""
self._image_token_cache = {}
self._image_token_retry_at = 0.0
# Merge provider_options into HttpOptions
http_options_dict: dict[str, Any] = {
"timeout": int(self.timeout * 1000), # requires milliseconds
@@ -245,71 +232,6 @@ class GeminiClient(GenAIClient):
# Gemini Pro Vision has a 1M token context window
return 1000000
def estimate_image_tokens(self, width: int, height: int) -> float:
"""Ask Gemini how many prompt tokens an image of these dimensions costs.
Gemini bills images far above the base pixel heuristic (Gemini 3 models
charge ~1100 tokens for a 320x180 frame, not ~46), and the cost differs
between model generations, so it is measured with the free count_tokens
endpoint and cached per (width, height). Falls back to the base heuristic
if the request fails, and does not probe again for
COUNT_TOKENS_RETRY_INTERVAL seconds.
"""
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
if time.monotonic() < self._image_token_retry_at:
return super().estimate_image_tokens(width, height)
img = Image.new("RGB", (width, height), (128, 128, 128))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=60)
image = types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
try:
baseline = self._count_tokens(["."])
with_image = self._count_tokens([".", image])
tokens = max(1, with_image - baseline)
except Exception as e:
self._image_token_retry_at = time.monotonic() + COUNT_TOKENS_RETRY_INTERVAL
logger.debug(
"Gemini image-token count failed for %dx%d (%s); using heuristic",
width,
height,
e,
)
return super().estimate_image_tokens(width, height)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"Gemini model '%s' uses %d tokens for %dx%d images",
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _count_tokens(self, contents: list[Any]) -> int:
"""Return Gemini's prompt-token count for contents, without generating."""
total = self.provider.models.count_tokens(
model=self.genai_config.model,
contents=contents,
config=types.CountTokensConfig(
http_options=types.HttpOptions(
timeout=COUNT_TOKENS_TIMEOUT_MS,
retry_options=types.HttpRetryOptions(attempts=1),
)
),
).total_tokens
if total is None:
raise ValueError("count_tokens returned no total")
return total
def chat_with_tools(
self,
messages: list[dict[str, Any]],
+47 -64
View File
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator
from typing import Any
from httpx import TimeoutException
from openai import AsyncOpenAI, OpenAI
from openai import OpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
@@ -15,7 +15,6 @@ from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__)
# gpt-transcribe replaced the singular `language` field with a `languages` array
# and rejects a request that sends both. Older transcription models
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
@@ -46,8 +45,8 @@ class OpenAIClient(GenAIClient):
provider: OpenAI
context_size: int | None = None
def _client_options(self) -> dict[str, Any]:
"""Build the constructor options shared by the sync and async clients.
def _init_provider(self) -> OpenAI:
"""Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider.
@@ -63,19 +62,7 @@ class OpenAIClient(GenAIClient):
if self.genai_config.base_url:
provider_opts["base_url"] = self.genai_config.base_url
return {"api_key": self.genai_config.api_key, **provider_opts}
def _init_provider(self) -> OpenAI:
"""Initialize the client."""
return OpenAI(**self._client_options())
def _init_async_provider(self) -> AsyncOpenAI:
"""Create an async client for streaming chat.
A new client is created per stream because its connection pool binds
to the event loop that first uses it.
"""
return AsyncOpenAI(**self._client_options())
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
def _send(
self,
@@ -425,61 +412,57 @@ class OpenAIClient(GenAIClient):
finish_reason = "stop"
usage_stats: dict[str, Any] | None = None
# Exiting these blocks, including when the caller disconnects and
# the generator is cancelled, closes the response and its connection
async with (
self._init_async_provider() as client,
await client.chat.completions.create(**request_params) as stream,
):
async for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
stream = self.provider.chat.completions.create(**request_params)
if not chunk or not chunk.choices:
continue
for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
choice = chunk.choices[0]
delta = choice.delta
if not chunk or not chunk.choices:
continue
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
choice = chunk.choices[0]
delta = choice.delta
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(
delta, "reasoning_content", None
) or getattr(delta, "reasoning", None)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Check for finish reason
if choice.finish_reason:
finish_reason = choice.finish_reason
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
# Extract reasoning deltas (reasoning_content or reasoning,
# depending on the server)
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
# Extract content deltas
if delta.content:
content_parts.append(delta.content)
yield ("content_delta", delta.content)
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
# Extract tool calls
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
fn = tc.function
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
if idx not in tool_calls_by_index:
tool_calls_by_index[idx] = {
"id": tc.id or "",
"name": fn.name if fn and fn.name else "",
"arguments": "",
}
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message
full_content = "".join(content_parts).strip() or None
+1 -1
View File
@@ -209,7 +209,7 @@ class NoticeStats(Model):
mutes = IntegerField(default=0)
first_seen = DateTimeField()
last_seen = DateTimeField()
# lifetime counts when the current analytics install ID was created
# watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0)
reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0)
+18 -38
View File
@@ -132,7 +132,24 @@ class NoticeRegistry:
def flush(self) -> None:
"""Write the repeats that batch_repeats kinds held back."""
with self._lock:
written = self._write_held()
held, self._held = self._held, {}
written = False
for row_id, repeats in held.items():
row = Notice.get_or_none(Notice.id == row_id)
# resolved while its repeats waited
if row is None:
continue
self._write_repeats(
row,
repeats.kind,
repeats.count,
repeats.last_seen,
repeats.params,
)
written = True
if written:
self._notify()
@@ -368,43 +385,6 @@ class NoticeRegistry:
if row.kind in NOTICE_KINDS
]
def reset_analytics_baseline(self) -> None:
"""Count analytics notices from now on, for a new analytics install ID."""
with self._lock:
# held repeats were raised before the reset, so they join the baseline
written = self._write_held()
NoticeStats.update(
reported_occurrences=NoticeStats.occurrences,
reported_acknowledgements=NoticeStats.acknowledgements,
reported_mutes=NoticeStats.mutes,
).execute()
if written:
self._notify()
def _write_held(self) -> bool:
# called with the lock held
held, self._held = self._held, {}
written = False
for row_id, repeats in held.items():
row = Notice.get_or_none(Notice.id == row_id)
# resolved while its repeats waited
if row is None:
continue
self._write_repeats(
row,
repeats.kind,
repeats.count,
repeats.last_seen,
repeats.params,
)
written = True
return written
def _write_repeats(
self,
row: Notice,
-10
View File
@@ -99,16 +99,6 @@ _KINDS = (
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
counts_repeats=False,
keep_latest=1,
reportable=False,
),
# raised while analytics is off; the row keeps a dismissal across restarts
NoticeKind(
"analytics_prompt",
NoticeSeverity.info,
"system",
link="/settings?page=systemTelemetry",
counts_repeats=False,
reportable=False,
),
)
+6 -7
View File
@@ -23,7 +23,7 @@ from frigate.detectors.detector_config import (
InputDTypeEnum,
ModelConfig,
)
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
from frigate.util.builtin import EventsPerSecond, load_labels
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.process import FrigateProcess
@@ -128,7 +128,7 @@ class DetectorRunner(FrigateProcess):
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
self.detection_queue = detection_queue
self.cameras = cameras
self.avg_speed = InferenceSpeed(avg_speed)
self.avg_speed = avg_speed
self.start_time = start_time
self.config = config
self.detector_config = detector_config
@@ -182,7 +182,7 @@ class DetectorRunner(FrigateProcess):
detector_publisher.publish(connection_id)
self.start_time.value = 0.0
self.avg_speed.update(duration)
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
detector_publisher.stop()
logger.info("Exited detection process...")
@@ -203,7 +203,7 @@ class AsyncDetectorRunner(FrigateProcess):
super().__init__(stop_event, PROCESS_PRIORITY_HIGH, name=name, daemon=True)
self.detection_queue = detection_queue
self.cameras = cameras
self.avg_speed = InferenceSpeed(avg_speed)
self.avg_speed = avg_speed
self.start_time = start_time
self.config = config
self.detector_config = detector_config
@@ -272,7 +272,7 @@ class AsyncDetectorRunner(FrigateProcess):
self._publisher.publish(connection_id)
# update timers
self.avg_speed.update(duration)
self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10
self.start_time.value = 0.0
def run(self) -> None:
@@ -328,8 +328,7 @@ class ObjectDetectProcess:
self.name = name
self.cameras = cameras
self.detection_queue = detection_queue
# stays 0 until the first inference, so stats can tell an idle detector
self.avg_inference_speed = Value("d", 0.0)
self.avg_inference_speed = Value("d", 0.01)
self.detection_start = Value("d", 0.0)
self.detect_process: FrigateProcess | None = None
self.config = config
+25 -31
View File
@@ -233,8 +233,7 @@ class PtzAutoTracker(threading.Thread):
and camera_config.onvif.autotracking.enabled_in_config
):
future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera, startup=True),
self.onvif.loop,
self._autotracker_setup(camera_config, camera), self.onvif.loop
)
# Wait for the coroutine to complete
future.result()
@@ -262,9 +261,7 @@ class PtzAutoTracker(threading.Thread):
self.config_subscriber.stop()
logger.info("Exiting autotracker...")
async def _autotracker_setup(
self, camera_config: CameraConfig, camera: str, startup: bool = False
):
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
logger.debug(f"{camera}: Autotracker init")
self.tracked_object[camera] = None
@@ -291,34 +288,35 @@ class PtzAutoTracker(threading.Thread):
self._disable(camera, "Unable to initialize onvif")
return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
self._disable(camera, "FOV relative movement not supported")
return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
self._disable(camera, "FOV relative movement not supported")
return
move_status_supported = await self.onvif.get_service_capabilities(camera)
move_status_supported = await self.onvif.get_service_capabilities(camera)
if str(move_status_supported).lower() != "true":
self._disable(camera, "ONVIF MoveStatus not supported")
return
if str(move_status_supported).lower() != "true":
self._disable(camera, "ONVIF MoveStatus not supported")
return
await self.onvif.get_camera_status(camera)
if self.onvif.cams[camera]["init"]:
await self.onvif.get_camera_status(camera)
# movement queue with asyncio on OnvifController loop
asyncio.run_coroutine_threadsafe(
self._process_move_queue(camera), self.onvif.loop
)
# movement queue with asyncio on OnvifController loop
asyncio.run_coroutine_threadsafe(
self._process_move_queue(camera), self.onvif.loop
)
if camera_config.onvif.autotracking.movement_weights:
(
self.ptz_metrics[camera].min_zoom.value,
self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera],
*self.move_coefficients[camera],
self.zoom_time[camera],
) = map(float, camera_config.onvif.autotracking.movement_weights)
if camera_config.onvif.autotracking.movement_weights:
(
self.ptz_metrics[camera].min_zoom.value,
self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera],
*self.move_coefficients[camera],
self.zoom_time[camera],
) = map(float, camera_config.onvif.autotracking.movement_weights)
if startup and camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera)
if camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera)
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
self.autotracker_init[camera] = True
@@ -333,7 +331,6 @@ class PtzAutoTracker(threading.Thread):
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
autotracking_config,
)
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
def _reset_tracked_object_metrics(self, camera: str) -> None:
self.tracked_object_metrics[camera] = {}
@@ -1272,9 +1269,6 @@ class PtzAutoTracker(threading.Thread):
# Wait for the coroutine to complete
future.result()
if not camera_config.onvif.autotracking.enabled:
return
if self.calibrating[camera]:
logger.debug(f"{camera}: Calibrating camera")
return
-36
View File
@@ -1,36 +0,0 @@
"""Shared fixtures for the analytics tests."""
from typing import Any
from frigate.analytics.context import ReportContext
from frigate.config import FrigateConfig
FRONT_CAMERA: dict[str, Any] = {
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
"detect": {"height": 720, "width": 1280, "fps": 5},
}
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
"""A parsed config; top-level keys in config replace the defaults here.
hwaccel_args is set so parsing never probes the test host's GPU.
"""
base: dict[str, Any] = {
"mqtt": {"host": "mqtt"},
"ffmpeg": {"hwaccel_args": []},
"cameras": {"front": FRONT_CAMERA},
}
return FrigateConfig(**{**base, **(config or {})})
def make_context(
config: FrigateConfig | None = None,
stats: dict[str, Any] | None = None,
notice_stats: list[dict[str, Any]] | None = None,
) -> ReportContext:
return ReportContext(
config=config or make_config(),
stats=stats or {},
notice_stats=notice_stats or [],
)
@@ -1,49 +0,0 @@
"""Tests for the analytics preview API."""
from unittest.mock import Mock, patch
from frigate.analytics import report
from frigate.analytics.collectors import hardware
from frigate.models import Notice, NoticeStats, User
from frigate.notices.registry import NoticeRegistry
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpAnalytics(BaseTestHttp):
def setUp(self):
super().setUp([Notice, NoticeStats, User])
stats = Mock()
stats.get_latest_stats.return_value = self.test_stats
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
for target, name, value in (
(report, "load_state", None),
(hardware.hardware_prober, "probe", []),
(hardware, "hwaccel_options", ("", [])),
):
patcher = patch.object(target, name, return_value=value)
patcher.start()
self.addCleanup(patcher.stop)
def test_preview_returns_the_report_without_sending_it(self):
with (
patch("frigate.analytics.transport.requests.post") as post,
AuthTestClient(self.app) as client,
):
response = client.get("/analytics/preview")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
self.assertEqual(body["schema_version"], 1)
self.assertEqual(body["cameras"]["total"], 1)
post.assert_not_called()
def test_preview_is_admin_only(self):
with AuthTestClient(self.app) as client:
response = client.get(
"/analytics/preview",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
self.assertEqual(response.status_code, 403)
@@ -1,133 +0,0 @@
"""Tests for the cameras collector."""
import unittest
from frigate.analytics.collectors import cameras
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
from frigate.test.analytics_helpers import make_config, make_context
MASK = {"coordinates": "0,0,1,0,1,1"}
CONFIG = {
"audio_transcription": {"enabled": True},
"record": {
"enabled": True,
"continuous": {"days": 3},
"alerts": {"retain": {"days": 14}},
"detections": {"retain": {"days": 45}},
},
"cameras": {
"front": {
"ffmpeg": {
"hwaccel_args": "preset-vaapi",
"inputs": [
{
"path": "rtsp://127.0.0.1:8554/front_sub",
"roles": ["detect"],
"input_args": "preset-rtsp-restream",
},
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
],
},
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
"zones": {"porch": MASK, "yard": MASK},
"motion": {"mask": {"tree": MASK}},
"objects": {
"mask": {"sign": MASK},
"filters": {"person": {"mask": {"bush": MASK}}},
},
"onvif": {"host": "10.0.0.9"},
},
"garage": {
"type": "lpr",
"ffmpeg": {
"hwaccel_args": ["-hwaccel", "vaapi"],
"inputs": [
{
"path": "rtsp://10.0.0.5/stream",
"roles": ["detect", "record", "audio"],
}
],
},
"audio": {"enabled": True},
"detect": {"height": 2160, "width": 3840, "fps": 15},
"record": {"enabled": False, "sub": {"enabled": True}},
},
"attic": {
"enabled": False,
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.6/stream", "roles": ["detect"]}]
},
"detect": {"enabled": True, "height": 480, "width": 640, "fps": 5},
},
"_replay_front": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
STATS = {
"cameras": {
"front": {"connection_quality": "excellent"},
"garage": {"connection_quality": "offline"},
"attic": {"connection_quality": "unusable"},
"_replay_front": {"connection_quality": "fair"},
}
}
class TestCamerasCollector(unittest.TestCase):
def test_aggregates_enabled_cameras_and_skips_replays(self):
section = cameras.collect(make_context(make_config(CONFIG), STATS))
self.assertEqual((section.total, section.enabled), (3, 2))
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
self.assertEqual(section.go2rtc_restream, 1)
self.assertEqual(section.separate_detect_stream, 1)
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
self.assertEqual((section.audio, section.audio_transcription), (1, 1))
self.assertEqual(section.sub_stream_record, 0)
self.assertEqual(section.connection_quality, {"excellent": 1})
def test_runtime_disabled_camera_skips_connection_quality(self):
config = make_config(CONFIG)
config.cameras["front"].enabled = False
section = cameras.collect(make_context(config, STATS))
self.assertEqual(section.connection_quality, {})
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
self.assertEqual(section.retain_days.motion, {"0": 1})
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
def test_buckets(self):
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
def test_preset_key(self):
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
def test_is_restream(self):
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
self.assertFalse(cameras.is_restream("rtsp://[bad"))
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
@@ -1,87 +0,0 @@
"""Tests for the detection collector."""
import os
import tempfile
import unittest
from unittest.mock import patch
from frigate.analytics.collectors import detection
from frigate.analytics.schema import ModelSource
from frigate.test.analytics_helpers import make_config, make_context
class TestDetectionCollector(unittest.TestCase):
def test_reports_each_model_with_its_mean_inference_speed(self):
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
stats = {
"detectors": {
"cpu": {"inference_speed": 10.0},
"cpu#2": {"inference_speed": 20.0},
},
"detection_fps": 12.346,
"skipped_fps": 0,
}
section = detection.collect(make_context(config, stats))
[model] = section.models
self.assertTrue(model.default)
self.assertEqual(model.detector, "cpu")
self.assertEqual(model.devices, 2)
self.assertEqual(model.model_type, "ssd")
self.assertEqual(model.input, "320x320")
self.assertEqual(model.source, ModelSource.default)
self.assertEqual(model.inference_ms, 15.0)
self.assertEqual(section.detection_fps, 12.35)
self.assertEqual(section.skipped_fps, 0.0)
def test_idle_detector_reports_no_speed(self):
config = make_config({"models": [{"devices": ["cpu"]}]})
stats = {"detectors": {"cpu": {"inference_speed": 0.0}}}
[model] = detection.collect(make_context(config, stats)).models
self.assertIsNone(model.inference_ms)
def test_named_scene_reports_no_name(self):
config = make_config(
{
"models": [
{"devices": ["cpu"]},
{
"scene": "backyard_thermal",
"devices": ["cpu"],
"path": "/thermal.tflite",
},
]
}
)
section = detection.collect(make_context(config))
self.assertEqual([m.default for m in section.models], [True, False])
self.assertNotIn("backyard_thermal", section.model_dump_json())
def test_inference_is_null_before_the_first_stats(self):
section = detection.collect(make_context())
self.assertIsNone(section.models[0].inference_ms)
def test_model_source(self):
with tempfile.TemporaryDirectory() as cache:
plus_model = os.path.join(cache, "abc123")
open(f"{plus_model}.json", "w").close()
with patch.object(detection, "MODEL_CACHE_DIR", cache):
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
self.assertEqual(
detection.model_source(os.path.join(cache, "no_info")),
ModelSource.custom,
)
self.assertEqual(detection.model_source(None), ModelSource.default)
self.assertEqual(
detection.model_source("/cpu_model.tflite"), ModelSource.default
)
self.assertEqual(
detection.model_source("/config/yolo.onnx"), ModelSource.custom
)
@@ -1,129 +0,0 @@
"""Tests for the features collector."""
import logging
import os
import unittest
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics.collectors import features
from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel
from frigate.models import User
from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
CONFIG = {
"mqtt": {"host": "mqtt", "enabled": True},
"genai": {
"local": {
"provider": "ollama",
"model": "llava",
"base_url": "http://ollama:11434",
"roles": ["descriptions", "embeddings"],
}
},
"semantic_search": {"enabled": True, "model": "local"},
"face_recognition": {"enabled": True, "model_size": "large"},
"birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True},
"proxy": {"header_map": {"user": "x-forwarded-user"}},
"camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}},
"cameras": {
"front": {
**FRONT_CAMERA,
"semantic_search": {
"triggers": {
"cat": {
"type": "description",
"data": "a cat",
"threshold": 0.8,
"actions": ["notification"],
}
}
},
}
},
}
class TestFeaturesCollector(unittest.TestCase):
def setUp(self):
migrate_db = SqliteDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([User])
def tearDown(self):
# close() leaves the queue's writer thread running against the deleted file
self.db.stop()
if not self.db.is_closed():
self.db.close()
for file in TEST_DB_CLEANUPS:
try:
os.remove(file)
except OSError:
pass
def test_collects_enrichments_genai_integrations_and_users(self):
for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")):
User.create(
username=username, role=role, password_hash="x", notification_tokens=[]
)
stats = {
"embeddings": {
"devices": {
"face_recognition": "OpenVINO GPU.0",
"semantic_search": "CUDA",
}
}
}
section = features.collect(make_context(make_config(CONFIG), stats))
self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu)
self.assertEqual(section.face_recognition.model_size, "large")
self.assertIsNone(section.lpr.device)
self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai)
self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda)
self.assertEqual(section.semantic_search.triggers, 1)
self.assertEqual(section.genai.providers, {"ollama": 1})
self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1})
self.assertEqual(section.birdseye.modes, ["motion", "alerts"])
self.assertTrue(section.mqtt)
self.assertTrue(section.proxy_auth)
self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1})
self.assertEqual((section.camera_groups, section.profiles), (1, 0))
self.assertFalse(section.plus_api_key)
class TestFeatureMappings(unittest.TestCase):
def test_enrichment_device_labels(self):
cases = {
"CPU": EnrichmentDevice.cpu,
"MIGraphX": EnrichmentDevice.migraphx,
"OpenVINO NPU": EnrichmentDevice.openvino_npu,
"OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu,
"OpenVINO (CPU)": EnrichmentDevice.openvino_cpu,
"OpenVINO": EnrichmentDevice.other,
"CoreML": EnrichmentDevice.other,
}
for label, device in cases.items():
with self.subTest(label=label):
self.assertEqual(features.enrichment_device(label), device)
self.assertIsNone(features.enrichment_device(None))
def test_models_named_after_a_provider_are_genai(self):
self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2)
self.assertEqual(
features.semantic_model("my-ollama"), SemanticSearchModel.genai
)
self.assertIsNone(features.semantic_model(None))
self.assertEqual(features.transcription_model("openai"), "genai")
@@ -1,71 +0,0 @@
"""Tests for the health collector."""
import unittest
from frigate.analytics.collectors import health
from frigate.test.analytics_helpers import make_context
class TestHealthCollector(unittest.TestCase):
def test_collects_uptime_cpu_timings_and_notices_since_the_baseline(self):
stats = {
"service": {"uptime": 7300, "retention_unmet": True},
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
"embeddings": {
"face_recognition_speed": 41.234,
"text_embedding_speed": 0,
"devices": {},
},
}
notice_stats = [
{
"kind": "detector_stuck",
"occurrences": 5,
"acknowledgements": 1,
"mutes": 0,
"reported_occurrences": 3,
"reported_acknowledgements": 1,
"reported_mutes": 0,
},
{
"kind": "shm_too_low",
"occurrences": 2,
"acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 2,
"reported_acknowledgements": 0,
"reported_mutes": 0,
},
{
"kind": "update_available",
"occurrences": 4,
"acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 0,
"reported_acknowledgements": 0,
"reported_mutes": 0,
},
]
section = health.collect(make_context(stats=stats, notice_stats=notice_stats))
self.assertEqual(section.uptime_hours, 2)
self.assertEqual(section.cpu_percent, 24)
self.assertEqual(section.enrichment_ms, {"face": 41.23})
self.assertTrue(section.retention_unmet)
self.assertEqual(
{
kind: (c.occurrences, c.acknowledgements, c.mutes)
for kind, c in section.notices.items()
},
{"detector_stuck": (2, 0, 0)},
)
def test_missing_stats_give_zeros(self):
section = health.collect(make_context())
self.assertEqual(
(section.uptime_hours, section.cpu_percent, section.enrichment_ms),
(0, 0, {}),
)
self.assertEqual(section.notices, {})
@@ -1,188 +0,0 @@
"""Tests for the install and hardware collectors."""
import os
import tempfile
import unittest
from collections import Counter
from types import SimpleNamespace
from unittest.mock import patch
from frigate.analytics.collectors import hardware, install
from frigate.analytics.collectors.common import closed, histogram, rate
from frigate.analytics.schema import (
Arch,
DecodeFamily,
GpuVendor,
HardwareKey,
ImageVariant,
InstallType,
)
from frigate.const import RECORD_DIR
from frigate.test.analytics_helpers import make_context
class TestCommon(unittest.TestCase):
def test_closed_falls_back_for_unknown_values(self):
self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64)
self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other)
self.assertEqual(closed(Arch, None, Arch.other), Arch.other)
def test_rate_rounds_and_rejects_bad_numbers(self):
self.assertEqual(rate("12.346"), 12.35)
self.assertEqual(rate(-3), 0.0)
self.assertEqual(rate(float("nan")), 0.0)
self.assertEqual(rate(float("inf")), 0.0)
self.assertEqual(rate(None), 0.0)
def test_histogram_drops_empty_buckets(self):
self.assertEqual(
histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2}
)
class TestInstallCollector(unittest.TestCase):
def test_collects_version_variant_and_platform(self):
with (
patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}),
patch.object(install, "install_type", return_value=InstallType.docker),
patch("platform.machine", return_value="aarch64"),
patch("platform.release", return_value="6.8.0-45-generic"),
patch("os.geteuid", return_value=1000),
):
section = install.collect(make_context())
self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6)
self.assertEqual(section.install_type, InstallType.docker)
self.assertEqual(section.arch, Arch.aarch64)
self.assertEqual(section.kernel, "6.8")
self.assertFalse(section.run_as_root)
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
self.assertEqual(install.image_variant(None), ImageVariant.dev)
self.assertEqual(install.image_variant(""), ImageVariant.dev)
self.assertEqual(install.image_variant("h8l"), ImageVariant.other)
def test_install_type_checks_the_add_on_first(self):
with patch("os.path.isfile", return_value=True):
self.assertEqual(install.install_type(), InstallType.ha_addon)
with (
patch("os.path.isfile", return_value=False),
patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}),
):
self.assertEqual(install.install_type(), InstallType.kubernetes)
env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"}
with (
patch("os.path.isfile", return_value=False),
patch.dict(os.environ, env, clear=True),
patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"),
):
self.assertEqual(install.install_type(), InstallType.docker)
def test_kernel_and_arch_fall_back(self):
self.assertEqual(install.kernel("weird"), "unknown")
self.assertEqual(install.arch("armv7l"), Arch.other)
self.assertEqual(install.arch("amd64"), Arch.x86_64)
class TestHardwareCollector(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def write(self, name: str, content: str) -> str:
path = os.path.join(self.dir.name, name)
with open(path, "w") as f:
f.write(content)
return path
def test_cpu_model_prefers_the_board_model(self):
board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00")
cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n")
with (
patch.object(hardware, "DEVICE_TREE_MODEL", board),
patch.object(hardware, "CPUINFO", cpuinfo),
):
self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0")
def test_cpu_model_reads_cpuinfo_and_truncates(self):
cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80)
with (
patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"),
patch.object(hardware, "CPUINFO", cpuinfo),
):
self.assertEqual(hardware.cpu_model(), "x" * 64)
def test_collects_gpus_decode_detection_and_storage(self):
stats = {
"gpu_usages": {
"NVIDIA GeForce RTX 3060": {"vendor": "nvidia"},
"mystery": {},
},
"service": {
"storage": {
RECORD_DIR: {
"total": 2048000.0,
"used": 512000.0,
"mount_type": "nfs4",
}
}
},
}
probed = [
SimpleNamespace(key="edgetpu:usb", count=2),
SimpleNamespace(key="new:thing", count=1),
SimpleNamespace(key="cpu", count=1),
]
families = [
SimpleNamespace(key="vaapi"),
SimpleNamespace(key="intel-qsv"),
SimpleNamespace(key="future"),
]
with (
patch.object(hardware.hardware_prober, "probe", return_value=probed),
patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)),
patch.object(hardware, "cpu_model", return_value="Intel(R) N100"),
patch("os.cpu_count", return_value=4),
patch(
"psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30)
),
):
section = hardware.collect(make_context(stats=stats))
self.assertEqual(section.cpu_model, "Intel(R) N100")
self.assertEqual(section.cpu_cores, 4)
self.assertEqual(section.memory_gb, 16)
self.assertEqual(
[(gpu.vendor, gpu.name) for gpu in section.gpus],
[
(GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"),
(GpuVendor.other, "mystery"),
],
)
self.assertEqual(
section.decode_families,
[DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other],
)
self.assertEqual(
section.detection_hardware,
{HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1},
)
self.assertEqual(section.storage.record_fs, "nfs4")
self.assertEqual(section.storage.record_total_gb, 2000)
self.assertEqual(section.storage.record_used_pct, 25)
def test_storage_without_stats_is_unknown_and_empty(self):
section = hardware.storage({})
self.assertEqual(
(section.record_fs, section.record_total_gb, section.record_used_pct),
("unknown", 0, 0),
)
-143
View File
@@ -1,143 +0,0 @@
"""Tests for building a report from the collectors."""
import json
import logging
import os
import unittest
from unittest.mock import Mock, patch
from peewee import SqliteDatabase
from peewee_migrate import Router
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics import report
from frigate.analytics.collectors import hardware
from frigate.analytics.schema import AnalyticsReport, InstallSection
from frigate.analytics.state import AnalyticsState
from frigate.models import User
from frigate.test.analytics_helpers import make_context
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
INSTALL = InstallSection(
version="0.19.0",
image_variant="dev",
install_type="docker",
arch="x86_64",
kernel="6.8",
run_as_root=False,
)
class TestBuildReport(unittest.TestCase):
def test_a_failing_collector_nulls_only_its_section(self):
collectors = {
"install": lambda ctx: INSTALL,
"hardware": Mock(side_effect=RuntimeError("boom")),
"detection": lambda ctx: None,
"cameras": lambda ctx: None,
"features": lambda ctx: None,
"health": lambda ctx: None,
}
with patch.dict(report.COLLECTORS, collectors):
built = report.build_report(make_context(), "a" * 32, sent_at=1790000000)
self.assertEqual(built.install, INSTALL)
self.assertIsNone(built.hardware)
self.assertEqual(built.schema_version, 1)
self.assertEqual(built.sent_at, 1790000000)
self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$")
class TestRealCollectors(unittest.TestCase):
def setUp(self):
migrate_db = SqliteDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([User])
def tearDown(self):
# close() leaves the queue's writer thread running against the deleted file
self.db.stop()
if not self.db.is_closed():
self.db.close()
for file in TEST_DB_CLEANUPS:
try:
os.remove(file)
except OSError:
pass
def test_every_section_builds_and_round_trips_through_json(self):
with (
patch.object(hardware.hardware_prober, "probe", return_value=[]),
patch.object(hardware, "hwaccel_options", return_value=("", [])),
):
built = report.build_report(make_context(), "a" * 32)
body = built.model_dump_json()
parsed = json.loads(body)
for section in (
"install",
"hardware",
"detection",
"cameras",
"features",
"health",
):
with self.subTest(section=section):
self.assertIsNotNone(parsed[section])
self.assertEqual(AnalyticsReport.model_validate_json(body), built)
def test_preview_uses_a_placeholder_id_before_the_first_report(self):
stats_emitter = Mock()
stats_emitter.get_latest_stats.return_value = {}
with (
patch.object(report, "load_state", return_value=None),
patch.object(hardware.hardware_prober, "probe", return_value=[]),
patch.object(hardware, "hwaccel_options", return_value=("", [])),
):
built = report.preview_report(make_context().config, stats_emitter, None)
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
def test_preview_counts_notices_only_once_opted_in(self):
stats_emitter = Mock()
stats_emitter.get_latest_stats.return_value = {}
registry = Mock()
registry.stats.return_value = [
{
"kind": "detector_stuck",
"occurrences": 3,
"acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 1,
"reported_acknowledgements": 0,
"reported_mutes": 0,
}
]
for state, expected in (
(None, {}),
(AnalyticsState("a" * 32, 0.0), {"detector_stuck": 2}),
):
with (
self.subTest(opted_in=state is not None),
patch.object(report, "load_state", return_value=state),
patch.object(hardware.hardware_prober, "probe", return_value=[]),
patch.object(hardware, "hwaccel_options", return_value=("", [])),
):
built = report.preview_report(
make_context().config, stats_emitter, registry
)
self.assertEqual(
{kind: c.occurrences for kind, c in built.health.notices.items()},
expected,
)
-349
View File
@@ -1,349 +0,0 @@
"""Tests for the analytics reporter's schedule and consent handling."""
import os
import tempfile
import threading
import unittest
from unittest.mock import Mock, patch
from frigate.analytics import reporter as reporter_module
from frigate.analytics.reporter import (
FIRST_DELAY_S,
INTERVAL_S,
JITTER_S,
PROMPT_KIND,
WAKE_S,
AnalyticsReporter,
)
from frigate.analytics.state import AnalyticsState, load_state, save_state
from frigate.analytics.transport import SendOutcome
from frigate.config.holder import ConfigHolder
from frigate.test.analytics_helpers import make_config
class TestAnalyticsReporter(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.path = os.path.join(self.dir.name, ".analytics.json")
self.config_path = os.path.join(self.dir.name, "config.yml")
self.save_config("telemetry:\n analytics: true\n")
self.now = 1_790_000_000.0
self.send = Mock(return_value=SendOutcome.accepted)
self.registry = Mock()
self.stats = Mock()
self.stats.get_latest_stats.return_value = {}
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
for name in ("raise_notice", "resolve_notice"):
patcher = patch.object(reporter_module, name)
setattr(self, name, patcher.start())
self.addCleanup(patcher.stop)
self.built = Mock()
self.built.model_dump_json.return_value = '{"report": 1}'
patcher = patch.object(reporter_module, "build_report", return_value=self.built)
self.build_report = patcher.start()
self.addCleanup(patcher.stop)
def save_config(self, text: str) -> None:
with open(self.config_path, "w") as f:
f.write(text)
def seed_state(self, last_attempt_at: float = 0.0) -> None:
save_state(AnalyticsState("a" * 32, last_attempt_at), self.path)
def reporter(self, path: str | None = None) -> AnalyticsReporter:
# uniform(a, b) returns a: the shortest startup delay and interval
rng = Mock()
rng.uniform.side_effect = lambda low, high: low
return AnalyticsReporter(
self.holder,
self.stats,
self.registry,
threading.Event(),
state_path=path or self.path,
config_path=self.config_path,
url="https://example.test/report",
send=self.send,
clock=lambda: self.now,
rng=rng,
)
def advance(self, seconds: float) -> None:
self.now += seconds
def test_waits_for_the_startup_delay_then_sends(self):
self.seed_state()
reporter = self.reporter()
reporter.tick()
self.send.assert_not_called()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once_with(
"https://example.test/report", '{"report": 1}'
)
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
def test_sends_once_per_interval(self):
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.assertEqual(self.send.call_count, 1)
self.advance(INTERVAL_S - JITTER_S)
reporter.tick()
self.assertEqual(self.send.call_count, 2)
def test_a_restart_inside_the_interval_does_not_send(self):
self.seed_state(self.now)
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_not_called()
def test_a_failed_send_waits_a_full_interval(self):
self.send.return_value = SendOutcome.failed
self.seed_state()
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.assertEqual(self.send.call_count, 1)
def assert_reset_before_the_state_file(self) -> None:
# the baseline must be in place before the file names the new ID
self.registry.reset_analytics_baseline.side_effect = lambda: self.assertIsNone(
load_state(self.path)
)
def test_the_opt_in_resets_the_baseline_and_writes_the_identity(self):
self.assert_reset_before_the_state_file()
reporter = self.reporter()
# consent is seen on the first wake, well before the first send
reporter.tick()
self.registry.reset_analytics_baseline.assert_called_once()
self.assertIsNotNone(load_state(self.path))
self.send.assert_not_called()
# the first report counts from that reset, so nothing resets again
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
self.registry.reset_analytics_baseline.assert_called_once()
def test_a_restart_with_saved_state_keeps_the_baseline(self):
self.seed_state()
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
self.registry.reset_analytics_baseline.assert_not_called()
def test_a_settings_opt_in_resets_the_baseline_when_saved(self):
self.holder.set(make_config())
reporter = self.reporter()
reporter.tick()
self.holder.set(make_config({"telemetry": {"analytics": True}}))
self.registry.reset_analytics_baseline.assert_called_once()
self.assertIsNotNone(load_state(self.path))
def test_a_re_opt_in_during_the_request_keeps_its_new_identity(self):
self.seed_state()
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
created = []
def flip_consent(*args):
self.holder.set(make_config())
self.holder.set(make_config({"telemetry": {"analytics": True}}))
created.append(load_state(self.path))
return SendOutcome.accepted
self.send.side_effect = flip_consent
reporter.tick()
# nothing after the send touches the new identity or its baseline
self.assertNotEqual(created[0].install_id, "a" * 32)
self.assertEqual(load_state(self.path), created[0])
self.registry.reset_analytics_baseline.assert_called_once()
def test_an_opt_in_without_a_writable_state_file_resets_again_later(self):
state_dir = os.path.join(self.dir.name, "missing")
reporter = self.reporter(os.path.join(state_dir, ".analytics.json"))
with self.assertLogs("frigate.analytics.reporter", "WARNING"):
reporter.tick()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_not_called()
os.mkdir(state_dir)
self.registry.reset_analytics_baseline.reset_mock()
self.advance(WAKE_S)
reporter.tick()
self.send.assert_called_once()
self.registry.reset_analytics_baseline.assert_called_once()
def test_an_opt_out_saved_only_to_the_file_stops_the_send(self):
self.seed_state()
self.save_config("telemetry:\n analytics: false\n")
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_not_called()
self.assertFalse(os.path.exists(self.path))
self.raise_notice.assert_not_called()
def test_reversing_a_file_only_opt_out_resets_the_baseline(self):
self.seed_state()
self.save_config("telemetry:\n analytics: false\n")
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.save_config("telemetry:\n analytics: true\n")
self.assert_reset_before_the_state_file()
self.advance(WAKE_S)
reporter.tick()
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
self.registry.reset_analytics_baseline.assert_called_once()
self.send.assert_called_once()
def test_yaml_truthy_spellings_in_the_file_count_as_consent(self):
for value in ("yes", "on", "1", "'true'"):
with self.subTest(value=value):
self.seed_state()
self.send.reset_mock()
self.save_config(f"telemetry:\n analytics: {value}\n")
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
self.assertTrue(os.path.exists(self.path))
def test_an_unreadable_config_file_falls_back_to_the_live_config(self):
self.seed_state()
self.save_config("telemetry: [unclosed\n")
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
def test_consent_withdrawn_while_the_report_builds_stops_the_send(self):
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
def withdraw(*args, **kwargs):
self.holder.set(make_config())
return self.built
self.build_report.side_effect = withdraw
reporter.tick()
self.send.assert_not_called()
self.assertFalse(os.path.exists(self.path))
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
self.seed_state()
reporter = self.reporter()
reporter.tick()
self.holder.set(make_config())
self.holder.set(make_config({"telemetry": {"analytics": True}}))
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.raise_notice.assert_called_once_with(PROMPT_KIND)
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
self.holder.set(make_config())
self.seed_state()
reporter = self.reporter()
reporter.tick()
reporter.tick()
self.raise_notice.assert_called_once_with(PROMPT_KIND)
self.assertFalse(os.path.exists(self.path))
self.send.assert_not_called()
def test_opting_in_resolves_the_prompt(self):
self.holder.set(make_config())
reporter = self.reporter()
reporter.tick()
self.holder.set(make_config({"telemetry": {"analytics": True}}))
reporter.tick()
self.resolve_notice.assert_called_once_with(PROMPT_KIND)
def test_safe_mode_touches_nothing(self):
self.holder.set(make_config({"safe_mode": True}))
self.seed_state()
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.raise_notice.assert_not_called()
self.assertTrue(os.path.exists(self.path))
self.send.assert_not_called()
def test_an_unwritable_config_dir_skips_sending_and_warns_once(self):
reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json"))
self.advance(FIRST_DELAY_S[0])
with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs:
reporter.tick()
self.advance(WAKE_S)
reporter.tick()
self.send.assert_not_called()
self.assertEqual(len(logs.output), 1)
def test_a_last_attempt_in_the_future_is_ignored(self):
self.seed_state(self.now + 10 * INTERVAL_S)
reporter = self.reporter()
self.advance(FIRST_DELAY_S[0])
reporter.tick()
self.send.assert_called_once()
def test_the_loop_survives_a_failing_tick(self):
reporter = self.reporter()
reporter.stop_event.set()
with (
patch.object(reporter, "tick", side_effect=RuntimeError("boom")),
self.assertLogs("frigate.analytics.reporter", "ERROR"),
):
reporter.run()
-137
View File
@@ -1,137 +0,0 @@
"""Tests for the analytics report schema."""
import inspect
import re
import unittest
from enum import Enum
from typing import Any, get_args, get_origin
from pydantic import BaseModel
import frigate.detectors.hardware as detection_hardware
import frigate.util.hwaccel as hwaccel
from frigate.analytics.schema import (
AnalyticsReport,
DecodeFamily,
HardwareKey,
HwaccelKey,
InputPresetKey,
NoticeKindKey,
)
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
from frigate.notices.types import NOTICE_KINDS
from frigate.util.hwaccel import HwaccelFamily
# the only str fields: generated ids and strings the hardware reports
ALLOWED_STRINGS = {
("AnalyticsReport", "install_id"),
("AnalyticsReport", "report_id"),
("InstallSection", "version"),
("InstallSection", "kernel"),
("HardwareSection", "cpu_model"),
("GpuInfo", "name"),
("StorageInfo", "record_fs"),
("DetectionModel", "input"),
}
def nested_models(annotation: Any) -> list[type[BaseModel]]:
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return [annotation]
return [model for arg in get_args(annotation) for model in nested_models(arg)]
def all_models(
model: type[BaseModel], seen: set[type[BaseModel]] | None = None
) -> list[type[BaseModel]]:
seen = set() if seen is None else seen
if model in seen:
return []
seen.add(model)
found = [model]
for field in model.model_fields.values():
for nested in nested_models(field.annotation):
found += all_models(nested, seen)
return found
def dict_key_types(annotation: Any) -> list[Any]:
keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else []
return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)]
class TestSchemaDiscipline(unittest.TestCase):
def test_every_field_has_a_description_and_public_flag(self):
for model in all_models(AnalyticsReport):
for name, field in model.model_fields.items():
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertTrue(field.description)
self.assertIsInstance(field.json_schema_extra, dict)
self.assertIsInstance(field.json_schema_extra.get("x-public"), bool)
def test_strings_are_limited_to_ids_and_hardware_names(self):
for model in all_models(AnalyticsReport):
properties = model.model_json_schema()["properties"]
for name, field in model.model_fields.items():
if field.annotation is not str:
continue
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertIn((model.__name__, name), ALLOWED_STRINGS)
self.assertTrue(
"maxLength" in properties[name] or "pattern" in properties[name]
)
def test_map_keys_are_enums(self):
for model in all_models(AnalyticsReport):
for name, field in model.model_fields.items():
for key_type in dict_key_types(field.annotation):
with self.subTest(field=f"{model.__name__}.{name}"):
self.assertTrue(issubclass(key_type, Enum))
class TestEnumsTrackTheirSources(unittest.TestCase):
def test_hardware_keys_cover_every_probe(self):
probed = set(
re.findall(
r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware)
)
)
self.assertTrue(probed)
self.assertLessEqual(probed, {key.value for key in HardwareKey})
def test_decode_families_cover_every_hwaccel_family(self):
families = {
value.key
for value in vars(hwaccel).values()
if isinstance(value, HwaccelFamily)
}
self.assertTrue(families)
self.assertLessEqual(families, {family.value for family in DecodeFamily})
def test_preset_keys_mirror_the_presets(self):
for enum, presets in (
(HwaccelKey, PRESETS_HW_ACCEL_DECODE),
(InputPresetKey, PRESETS_INPUT),
):
with self.subTest(enum=enum.__name__):
self.assertEqual(
{key.value for key in enum},
{name.removeprefix("preset-") for name in presets}
| {"custom", "none"},
)
def test_notice_keys_are_the_reportable_kinds(self):
self.assertEqual(
{key.value for key in NoticeKindKey},
{key for key, kind in NOTICE_KINDS.items() if kind.reportable},
)
self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey})
-64
View File
@@ -1,64 +0,0 @@
"""Tests for the analytics state file."""
import os
import tempfile
import unittest
from frigate.analytics.state import (
AnalyticsState,
delete_state,
load_state,
new_state,
save_state,
)
class TestAnalyticsState(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.path = os.path.join(self.dir.name, ".analytics.json")
def write(self, content: str) -> None:
with open(self.path, "w") as f:
f.write(content)
def test_missing_file_loads_as_none(self):
self.assertIsNone(load_state(self.path))
def test_save_then_load_round_trips(self):
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
self.assertTrue(save_state(state, self.path))
self.assertEqual(load_state(self.path), state)
def test_unusable_files_load_as_none(self):
for content in (
"{not json",
'["a"]',
'{"install_id": "short", "last_attempt_at": 1}',
'{"install_id": "' + "a" * 32 + '"}',
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
):
with self.subTest(content=content):
self.write(content)
self.assertIsNone(load_state(self.path))
def test_save_reports_failure_instead_of_raising(self):
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
self.assertFalse(save_state(new_state(), missing))
def test_new_state_has_a_hex_id_and_no_attempt(self):
state = new_state()
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
self.assertEqual(state.last_attempt_at, 0.0)
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
save_state(new_state(), self.path)
delete_state(self.path)
delete_state(self.path)
self.assertFalse(os.path.exists(self.path))
-64
View File
@@ -1,64 +0,0 @@
"""Tests for sending a report."""
import unittest
from unittest.mock import Mock, patch
import requests
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
from frigate.version import VERSION
URL = "https://example.test/report"
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
return patch(
"frigate.analytics.transport.requests.post",
return_value=Mock(status_code=status, text=text),
side_effect=side_effect,
)
class TestSendReport(unittest.TestCase):
def test_posts_the_body_as_json_with_a_user_agent(self):
with post() as mock_post:
outcome = send_report(URL, '{"a":1}')
self.assertIs(outcome, SendOutcome.accepted)
args, kwargs = mock_post.call_args
self.assertEqual(args[0], URL)
self.assertEqual(kwargs["data"], b'{"a":1}')
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
self.assertFalse(kwargs["allow_redirects"])
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
def test_maps_statuses_to_outcomes(self):
cases = (
(200, SendOutcome.accepted),
(400, SendOutcome.rejected),
(429, SendOutcome.rate_limited),
(302, SendOutcome.failed),
(500, SendOutcome.failed),
)
for status, outcome in cases:
with self.subTest(status=status), post(status):
self.assertIs(send_report(URL, "{}"), outcome)
def test_logs_the_rejection_reason(self):
with (
post(400, "install_id: bad"),
self.assertLogs("frigate.analytics.transport", "DEBUG") as logs,
):
send_report(URL, "{}")
self.assertIn("install_id: bad", logs.output[0])
def test_a_network_error_fails_without_raising(self):
# the user can't act on a failed send, so it stays out of warnings
with (
post(side_effect=requests.ConnectionError("down")),
self.assertNoLogs("frigate.analytics.transport", "WARNING"),
):
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)
+1 -14
View File
@@ -1,10 +1,9 @@
"""Tests for frigate.util.builtin helpers."""
import unittest
from multiprocessing import Value
from unittest.mock import patch
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
from frigate.util.builtin import EventsPerSecond
class TestEventsPerSecond(unittest.TestCase):
@@ -63,17 +62,5 @@ class TestEventsPerSecond(unittest.TestCase):
self.assertAlmostEqual(eps.eps(), 20.0)
class TestInferenceSpeed(unittest.TestCase):
def test_first_inference_seeds_the_average(self) -> None:
metric = Value("d", 0.0)
speed = InferenceSpeed(metric)
speed.update(0.02)
self.assertEqual(metric.value, 0.02)
speed.update(0.12)
self.assertAlmostEqual(metric.value, 0.03)
if __name__ == "__main__":
unittest.main()
-46
View File
@@ -96,52 +96,6 @@ class TestCameraWatchdogReset(unittest.TestCase):
watchdog.start_ffmpeg_detect.assert_called_once()
class TestCameraWatchdogStop(unittest.TestCase):
def _build_stopping_watchdog(self, capture_reading: bool) -> CameraWatchdog:
watchdog = build_watchdog()
watchdog.ffmpeg_detect_process = MagicMock()
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = capture_reading
return watchdog
def test_live_capture_thread_terminates_without_reading_stdout(self):
watchdog = self._build_stopping_watchdog(capture_reading=True)
process = watchdog.ffmpeg_detect_process
with patch("frigate.video.ffmpeg.stop_ffmpeg") as stop_ffmpeg:
watchdog.stop_all_ffmpeg()
process.terminate.assert_called_once()
process.wait.assert_called_once_with(timeout=30)
process.kill.assert_not_called()
process.communicate.assert_not_called()
stop_ffmpeg.assert_not_called()
self.assertIsNone(watchdog.ffmpeg_detect_process)
def test_live_capture_thread_kills_ffmpeg_that_ignores_sigterm(self):
watchdog = self._build_stopping_watchdog(capture_reading=True)
process = watchdog.ffmpeg_detect_process
process.wait.side_effect = [sp.TimeoutExpired("ffmpeg", 30), 0]
watchdog.stop_all_ffmpeg()
process.kill.assert_called_once()
self.assertEqual(process.wait.call_args_list, [call(timeout=30), call()])
process.communicate.assert_not_called()
def test_joined_capture_thread_uses_stop_ffmpeg(self):
watchdog = self._build_stopping_watchdog(capture_reading=False)
process = watchdog.ffmpeg_detect_process
watchdog.capture_thread.is_alive.side_effect = [True, False]
with patch("frigate.video.ffmpeg.stop_ffmpeg") as stop_ffmpeg:
watchdog.stop_all_ffmpeg()
stop_ffmpeg.assert_called_once_with(process, watchdog.logger)
process.terminate.assert_not_called()
self.assertIsNone(watchdog.ffmpeg_detect_process)
class TestCameraWatchdogStreamHealth(unittest.TestCase):
def _build_watchdog(
self, sub_enabled: bool = True, output_args: dict | None = None
-5
View File
@@ -18,11 +18,6 @@ from frigate.util.builtin import deep_merge
class TestConfig(unittest.TestCase):
def test_analytics_is_off_by_default(self):
frigate_config = FrigateConfig(**self.minimal)
self.assertFalse(frigate_config.telemetry.analytics)
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
-25
View File
@@ -83,30 +83,5 @@ class TestSwapRuntimeConfig(unittest.TestCase):
app.genai_manager.update_config.assert_called_once_with(config)
class TestConfigHolder(unittest.TestCase):
def test_set_passes_the_new_config_to_subscribers(self) -> None:
holder = ConfigHolder(MagicMock(name="boot_config"))
listener = MagicMock()
holder.subscribe(listener)
config = MagicMock(name="new_config")
holder.set(config)
listener.assert_called_once_with(config)
def test_a_failing_subscriber_does_not_block_the_swap(self) -> None:
holder = ConfigHolder(MagicMock(name="boot_config"))
after = MagicMock()
holder.subscribe(MagicMock(side_effect=RuntimeError("boom")))
holder.subscribe(after)
config = MagicMock(name="new_config")
with self.assertLogs("frigate.config.holder", "ERROR"):
holder.set(config)
self.assertIs(holder.config, config)
after.assert_called_once_with(config)
if __name__ == "__main__":
unittest.main()
+7 -200
View File
@@ -145,72 +145,12 @@ def _openai_chunk(content=None, tool_calls=None, finish_reason=None, usage=None)
return SimpleNamespace(choices=[choice], usage=usage)
class _FakeAsyncOpenAIStream:
"""Mimics openai.AsyncStream: async iterable and async context manager.
With ``stall_after`` set, the stream stops producing after that many
chunks, like a model that stops sending tokens, and sets ``stalled``.
"""
def __init__(self, chunks, stall_after=None):
self._chunks = chunks
self._stall_after = stall_after
self.stalled = asyncio.Event()
self.close = AsyncMock()
async def __aiter__(self):
for i, chunk in enumerate(self._chunks):
if i == self._stall_after:
self.stalled.set()
await asyncio.Event().wait()
yield chunk
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.close()
class _FakeAsyncOpenAI:
"""Mimics openai.AsyncOpenAI: async context manager with a completions API."""
def __init__(self, create):
self.chat = SimpleNamespace(completions=SimpleNamespace(create=create))
self.close = AsyncMock()
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.close()
class TestOpenAIProvider(unittest.TestCase):
def _client(self):
return _make_client(
"openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1"
)
def _streaming_client(self, create):
"""Build a client whose async completions call is ``create``."""
client = self._client()
async_client = _FakeAsyncOpenAI(create)
client._init_async_provider = MagicMock(return_value=async_client)
return client, async_client
def test_async_client_uses_configured_options(self):
from openai import AsyncOpenAI
client = self._client()
async_client = client._init_async_provider()
try:
self.assertIsInstance(async_client, AsyncOpenAI)
self.assertEqual(async_client.base_url, client.provider.base_url)
self.assertEqual(async_client.api_key, "k")
finally:
asyncio.run(async_client.close())
def test_stream_tool_call_arguments_are_dict(self):
# Arguments arrive split across chunks, as the real API streams them.
chunks = [
@@ -222,9 +162,8 @@ class TestOpenAIProvider(unittest.TestCase):
_openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]),
_openai_chunk(finish_reason="tool_calls"),
]
client, _ = self._streaming_client(
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
)
client = self._client()
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
final = _final_message(_collect(client, SIMPLE_MESSAGES))
self.assertEqual(final["finish_reason"], "tool_calls")
@@ -238,9 +177,8 @@ class TestOpenAIProvider(unittest.TestCase):
_openai_chunk(content="lo"),
_openai_chunk(finish_reason="stop"),
]
client, _ = self._streaming_client(
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks))
)
client = self._client()
client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
events = _collect(client, SIMPLE_MESSAGES)
deltas = [v for (k, v) in events if k == "content_delta"]
@@ -248,84 +186,14 @@ class TestOpenAIProvider(unittest.TestCase):
self.assertEqual(_final_message(events)["content"], "hello")
def test_multimodal_message_does_not_crash(self):
client, _ = self._streaming_client(
AsyncMock(
return_value=_FakeAsyncOpenAIStream(
[_openai_chunk(content="ok", finish_reason="stop")]
)
)
client = self._client()
client.provider.chat.completions.create = MagicMock(
return_value=iter([_openai_chunk(content="ok", finish_reason="stop")])
)
# Passing the OpenAI-native multimodal list through must not raise.
final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok")
def test_stream_uses_async_client_and_closes_it(self):
# The blocking sync client must never be used on the event loop.
stream = _FakeAsyncOpenAIStream(
[_openai_chunk(content="ok", finish_reason="stop")]
)
create = AsyncMock(return_value=stream)
client, async_client = self._streaming_client(create)
client.provider.chat.completions.create = MagicMock()
final = _final_message(_collect(client, SIMPLE_MESSAGES))
self.assertEqual(final["content"], "ok")
self.assertTrue(create.await_args.kwargs["stream"])
client.provider.chat.completions.create.assert_not_called()
stream.close.assert_awaited_once()
async_client.close.assert_awaited_once()
def test_client_closed_when_caller_leaves_while_opening(self):
# Cancelling before the response arrives must still close the client.
async def _run():
opening = asyncio.Event()
async def hang(**kwargs):
opening.set()
await asyncio.Event().wait()
client, async_client = self._streaming_client(hang)
gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
)
task = asyncio.create_task(gen.__anext__())
await opening.wait()
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
return async_client
async_client = asyncio.run(_run())
async_client.close.assert_awaited_once()
def test_stream_closed_when_caller_leaves_mid_stream(self):
# Cancelling while waiting on a stalled model must close the stream
# right away instead of waiting for the next chunk.
async def _run():
stream = _FakeAsyncOpenAIStream(
[_openai_chunk(content="a"), _openai_chunk(finish_reason="stop")],
stall_after=1,
)
client, async_client = self._streaming_client(
AsyncMock(return_value=stream)
)
gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
)
first = await gen.__anext__()
task = asyncio.create_task(gen.__anext__())
await stream.stalled.wait()
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
return first, stream, async_client
first, stream, async_client = asyncio.run(_run())
self.assertEqual(first, ("content_delta", "a"))
stream.close.assert_awaited_once()
async_client.close.assert_awaited_once()
# ---------------------------------------------------------------------------
# Gemini
@@ -431,56 +299,6 @@ class TestGeminiProvider(unittest.TestCase):
self.assertEqual(final["content"], "ok")
class TestGeminiImageTokens(unittest.TestCase):
def _client(self, count_tokens):
client = _make_client("gemini", model="gemini-3-flash", api_key="k")
client.provider = SimpleNamespace(
models=SimpleNamespace(count_tokens=count_tokens)
)
return client
def test_measures_image_cost_and_caches_per_size(self):
count_tokens = MagicMock(
side_effect=lambda model, contents, config: SimpleNamespace(
total_tokens=2 if len(contents) == 1 else 1102
)
)
client = self._client(count_tokens)
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
self.assertEqual(client.estimate_image_tokens(320, 180), 1100)
self.assertEqual(count_tokens.call_count, 2)
image = count_tokens.call_args.kwargs["contents"][1]
self.assertEqual(image.inline_data.mime_type, "image/jpeg")
self.assertEqual(count_tokens.call_args.kwargs["model"], "gemini-3-flash")
http_options = count_tokens.call_args.kwargs["config"].http_options
self.assertEqual(http_options.timeout, 5000)
self.assertEqual(http_options.retry_options.attempts, 1)
client.estimate_image_tokens(640, 360)
self.assertEqual(count_tokens.call_count, 4)
def test_failure_falls_back_to_heuristic_and_waits_before_retrying(self):
count_tokens = MagicMock(side_effect=RuntimeError("offline"))
client = self._client(count_tokens)
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1000):
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
self.assertEqual(client.estimate_image_tokens(640, 360), 640 * 360 / 1250)
self.assertEqual(count_tokens.call_count, 1)
with patch("frigate.genai.plugins.gemini.time.monotonic", return_value=1300):
client.estimate_image_tokens(320, 180)
self.assertEqual(count_tokens.call_count, 2)
def test_missing_total_falls_back_to_heuristic(self):
client = self._client(
MagicMock(return_value=SimpleNamespace(total_tokens=None))
)
self.assertEqual(client.estimate_image_tokens(320, 180), 320 * 180 / 1250)
# ---------------------------------------------------------------------------
# Ollama
# ---------------------------------------------------------------------------
@@ -1122,17 +940,6 @@ class TestAzureOpenAITranscribe(unittest.TestCase):
self.assertIsInstance(client.provider, AzureOpenAI)
self.assertTrue(client.supports_transcription)
def test_streaming_routes_through_async_azure_client(self):
from openai import AsyncAzureOpenAI
client = self._client()
async_client = client._init_async_provider()
try:
self.assertIsInstance(async_client, AsyncAzureOpenAI)
self.assertEqual(async_client.base_url, client.provider.base_url)
finally:
asyncio.run(async_client.close())
def test_transcribe_inherited(self):
client = self._client()
create = MagicMock(return_value="azure text")
+2 -3
View File
@@ -20,7 +20,6 @@ from frigate.api.media_auth import (
resolve_media_uri,
)
from frigate.config import FrigateConfig
from frigate.const import EXPORT_DIR
from frigate.models import Event, Export, Recordings, ReviewSegment
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
@@ -246,8 +245,8 @@ class TestExportResolution(unittest.TestCase):
camera=camera,
name=f"export-{export_id}",
date=int(datetime.datetime.now().timestamp()),
video_path=os.path.join(EXPORT_DIR, filename),
thumb_path=os.path.join(EXPORT_DIR, f"{filename}.jpg"),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,
).execute()
-45
View File
@@ -46,16 +46,6 @@ class TestNoticeKinds(unittest.TestCase):
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
def test_analytics_prompt_links_to_telemetry_settings(self):
kind = NOTICE_KINDS["analytics_prompt"]
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
self.assertFalse(kind.counts_repeats)
self.assertFalse(kind.reportable)
def test_update_available_is_not_reported(self):
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
class RegistryTestCase(unittest.TestCase):
def setUp(self):
@@ -357,41 +347,6 @@ class TestNoticeRegistry(RegistryTestCase):
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
class TestAnalyticsBaseline(RegistryTestCase):
def test_reset_counts_from_now_and_new_kinds_start_at_zero(self):
stuck = {"scope": "ov", "params": {"detector": "ov"}}
self.registry.raise_notice("detector_stuck", **stuck)
self.registry.raise_notice("detector_stuck", **stuck)
self.registry.reset_analytics_baseline()
self.registry.raise_notice("detector_stuck", **stuck)
self.registry.raise_notice(
"skipped_detections", scope="front_door", params={"pct": 12.5}
)
rows = {row["kind"]: row for row in self.registry.stats()}
self.assertEqual(
(
rows["detector_stuck"]["occurrences"],
rows["detector_stuck"]["reported_occurrences"],
),
(3, 2),
)
self.assertEqual(rows["skipped_detections"]["reported_occurrences"], 0)
@patch.dict(NOTICE_KINDS, TEST_KINDS)
def test_reset_puts_held_repeats_in_the_baseline(self):
for _ in range(3):
self.registry.raise_notice("batched")
self.registry.reset_analytics_baseline()
self.registry.flush()
[row] = self.registry.stats()
self.assertEqual((row["occurrences"], row["reported_occurrences"]), (3, 3))
self.assertEqual(self.registry.active()[0]["count"], 3)
class TestApply(RegistryTestCase):
def test_each_action_reaches_its_method(self):
self.registry.apply(
+1 -13
View File
@@ -690,26 +690,14 @@ class CameraWatchdog(threading.Thread):
def stop_all_ffmpeg(self):
"""Stop all ffmpeg processes (detection and others)."""
logger.debug(f"Stopping all ffmpeg processes for {self.config.name}")
capture_reading = False
if self.capture_thread is not None and self.capture_thread.is_alive():
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
capture_reading = True
self.logger.warning(
f"Capture thread for {self.config.name} did not stop gracefully."
)
if self.ffmpeg_detect_process is not None:
# A live capture thread drains stdout, so communicate() would race it
# for the pipe. Without a reader, ffmpeg needs communicate() to exit.
if capture_reading:
self.ffmpeg_detect_process.terminate()
try:
self.ffmpeg_detect_process.wait(timeout=30)
except sp.TimeoutExpired:
self.ffmpeg_detect_process.kill()
self.ffmpeg_detect_process.wait()
else:
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
self.ffmpeg_detect_process = None
for p in self.ffmpeg_other_processes[:]:
if p["process"] is not None:
-61
View File
@@ -1,61 +0,0 @@
"""Generate the analytics report JSON Schema from the Pydantic models.
The committed docs/static/frigate-analytics-schema.json documents every field
for the ingest Worker's aggregation job and the docs page. It is exact for this
version only, so the Worker validates just the envelope against it. Never edit
it by hand, and never run a formatter over it.
Usage (from the repository root):
python3 generate_analytics_schema.py # write the schema
python3 generate_analytics_schema.py --check # CI guard: fail if stale
"""
import argparse
import json
import sys
from pathlib import Path
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json"
def render() -> str:
"""The JSON Schema for the current report models."""
schema = AnalyticsReport.model_json_schema()
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json"
schema["x-schema-version"] = SCHEMA_VERSION
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
def main() -> int:
"""Write or check the schema file; returns the process exit code."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--check", action="store_true", help="fail if the committed schema is stale"
)
args = parser.parse_args()
rendered = render()
if args.check:
current = OUTPUT.read_text() if OUTPUT.exists() else ""
if current != rendered:
print(
f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py",
file=sys.stderr,
)
return 1
print(f"{OUTPUT} is up to date")
return 0
OUTPUT.write_text(rendered)
print(f"Wrote {OUTPUT}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -3
View File
@@ -47,8 +47,8 @@ from fastapi.routing import APIRoute
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from frigate.api import app as main_app
from frigate.api import (
analytics,
auth,
camera,
chat,
@@ -65,7 +65,6 @@ from frigate.api import (
record,
review,
)
from frigate.api import app as main_app
from frigate.api.auth import require_admin_by_default
logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -139,7 +138,6 @@ ACCESS_NOTES = {
# Mirrors the router set wired up in frigate.api.fastapi_app.
ROUTERS = [
analytics.router,
auth.router,
camera.router,
chat.router,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,126 +0,0 @@
/**
* Section reset button visibility -- MEDIUM tier.
*
* Reset deletes the section from config.yml, which fails when the key isn't
* in the file. The button only shows when the section's values must have
* come from the YAML.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect, type FrigateApp } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import {
configFactory,
type DeepPartial,
} 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",
),
);
async function openSettings(
app: FrigateApp,
url: string,
overrides: DeepPartial<ReturnType<typeof configFactory>> = {},
) {
const { page } = app;
const config = configFactory(overrides);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { cameras: {}, go2rtc: { streams: {} } } }),
);
await app.goto(url);
}
const resetToDefault = (page: Page) =>
page.getByRole("button", { name: "Reset to Default" });
const resetToGlobal = (page: Page) =>
page.getByRole("button", { name: "Reset to Global" });
test.describe("section reset visibility @medium", () => {
test("global section at its defaults hides reset", async ({ frigateApp }) => {
await openSettings(frigateApp, "/settings?page=globalRecording");
await expect(
frigateApp.page.getByText("Retention", { exact: false }).first(),
).toBeVisible();
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
});
test("global section with a changed value shows reset", async ({
frigateApp,
}) => {
await openSettings(frigateApp, "/settings?page=globalRecording", {
record: { expire_interval: 30 },
});
await expect(resetToDefault(frigateApp.page)).toBeVisible();
});
test("global ffmpeg with only resolved hwaccel args hides reset", async ({
frigateApp,
}) => {
await openSettings(frigateApp, "/settings?page=globalFfmpeg");
await expect(
frigateApp.page
.getByText("Hardware acceleration", { exact: false })
.first(),
).toBeVisible();
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
});
test("camera live with only backend streams hides reset", async ({
frigateApp,
}) => {
await openSettings(
frigateApp,
"/settings?page=cameraLivePlayback&camera=front_door",
);
await expect(
frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
}),
).toBeVisible();
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
});
test("camera live with a quality override shows reset", async ({
frigateApp,
}) => {
await openSettings(
frigateApp,
"/settings?page=cameraLivePlayback&camera=front_door",
{ cameras: { front_door: { live: { quality: 4 } } } },
);
await expect(resetToGlobal(frigateApp.page)).toBeVisible();
});
test("camera ffmpeg never shows reset", async ({ frigateApp }) => {
await openSettings(
frigateApp,
"/settings?page=cameraFfmpeg&camera=front_door",
{ cameras: { front_door: { ffmpeg: { output_args: { record: "x" } } } } },
);
await expect(
frigateApp.page
.getByText("Hardware acceleration", { exact: false })
.first(),
).toBeVisible();
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
});
});
@@ -1,54 +0,0 @@
/**
* Telemetry settings: the analytics preview shows the report Frigate would
* send, fetched only when opened.
*/
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const PREVIEW = {
schema_version: 1,
install_id: "0".repeat(32),
install: { version: "0.19.0-test" },
};
test.describe("telemetry settings: analytics preview @medium", () => {
test("shows the report on demand", async ({ frigateApp }) => {
let requests = 0;
const page = frigateApp.page;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { telemetry: {} } }),
);
await page.route("**/api/analytics/preview", (route) => {
requests += 1;
return route.fulfill({ json: PREVIEW });
});
await frigateApp.goto("/settings?page=systemTelemetry");
const button = page.getByRole("button", { name: "Preview the report" });
await expect(button).toBeVisible();
expect(requests).toBe(0);
await button.click();
await expect(page.getByTestId("analytics-preview")).toContainText(
'"schema_version": 1',
);
expect(requests).toBe(1);
});
});
-1
View File
@@ -21,7 +21,6 @@
"1hour": "1 hour",
"12hours": "12 hours",
"24hours": "24 hours",
"custom": "Custom...",
"pm": "pm",
"am": "am",
"yr": "{{time}}yr",
-4
View File
@@ -226,10 +226,6 @@
"telemetry": {
"label": "Telemetry",
"description": "System telemetry and stats options including GPU and network bandwidth monitoring.",
"analytics": {
"label": "Share anonymous analytics",
"description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on."
},
"network_interfaces": {
"label": "Network interfaces",
"description": "List of network interface name prefixes to monitor for bandwidth statistics."
-12
View File
@@ -1,9 +1,4 @@
{
"analyticsPreview": {
"show": "Preview the report",
"desc": "Frigate sends this report once a day while sharing is on. It never includes camera names, zones, labels, or addresses.",
"error": "Unable to build the preview"
},
"documentTitle": {
"default": "Settings - Frigate",
"authentication": "Authentication Settings - Frigate",
@@ -1254,15 +1249,8 @@
"1hour": "Suspend for 1 hour",
"12hours": "Suspend for 12 hours",
"24hours": "Suspend for 24 hours",
"custom": "Suspend until...",
"untilRestart": "Suspend until restart"
},
"customSuspension": {
"title": "Custom suspension time",
"description": "Suspend notifications for this camera until the selected time.",
"untilLabel": "Suspend until",
"invalidTime": "Pick a time in the future."
},
"cancelSuspension": "Cancel Suspension",
"toast": {
"success": {
+1 -2
View File
@@ -56,8 +56,7 @@
"object_processing_behind": "Tracked object processing could not keep up, so camera frames were dropped. Check CPU utilization, and whether CPU limits or pinning on the container or VM are leaving Frigate too little CPU",
"failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}",
"update_available": "Frigate {{version}} is available",
"analytics_prompt": "Consider sharing anonymous usage statistics to help the developers improve Frigate"
"update_available": "Frigate {{version}} is available"
},
"streamPrefix": "Stream {{index}}: {{message}}",
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
@@ -53,7 +53,6 @@ const audio: SectionConfigOverrides = {
},
global: {
restartRequired: ["num_threads"],
runtimeFields: ["filters"],
},
camera: {
restartRequired: ["num_threads"],
@@ -103,6 +103,7 @@ const ffmpeg: SectionConfigOverrides = {
hiddenFields: ["retry_interval"],
advancedFields: ["path", "global_args", "gpu"],
overrideFields: [
"inputs",
"path",
"global_args",
"input_args",
@@ -168,7 +169,6 @@ const ffmpeg: SectionConfigOverrides = {
},
global: {
restartRequired: [],
runtimeFields: ["hwaccel_args"],
fieldOrder: [
"hwaccel_args",
"path",
@@ -194,8 +194,6 @@ const ffmpeg: SectionConfigOverrides = {
},
},
camera: {
// reset would delete the camera's inputs along with its overrides
hideReset: true,
fieldGroups: {
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
},
@@ -24,7 +24,6 @@ const live: SectionConfigOverrides = {
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
overrideFields: ["transcode", "height", "quality"],
},
global: {
restartRequired: ["streams", "height", "quality"],
@@ -16,7 +16,6 @@ const modelFields = [
const models: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors",
hideReset: true,
// the default-model rule must be enforced as the list is edited, not
// only when the form is submitted
liveValidate: true,
@@ -116,7 +116,6 @@ const record: SectionConfigOverrides = {
},
global: {
restartRequired: [],
runtimeFields: ["export.hwaccel_args"],
},
camera: {
restartRequired: [],
@@ -3,17 +3,9 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced/reference",
fieldDocs: {
analytics: "/configuration/advanced/analytics",
},
restartRequired: ["version_check"],
fieldOrder: ["analytics", "network_interfaces", "stats", "version_check"],
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
uiSchema: {
analytics: {
"ui:after": { render: "AnalyticsPreview" },
},
},
},
};
@@ -1,56 +0,0 @@
import { useState } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
export default function AnalyticsPreview() {
const { t } = useTranslation("views/settings");
const [open, setOpen] = useState(false);
// built on demand, since building runs every collector on the server
const { data, error } = useSWR<Record<string, unknown>>(
open ? "analytics/preview" : null,
{ revalidateOnFocus: false },
);
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
{open ? (
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="size-4" />
)}
{t("analyticsPreview.show")}
</Button>
</CollapsibleTrigger>
<div className="mt-1 text-xs text-muted-foreground">
{t("analyticsPreview.desc")}
</div>
<CollapsibleContent className="mt-2">
{error ? (
<div className="text-sm text-danger">
{t("analyticsPreview.error")}
</div>
) : data ? (
<pre
data-testid="analytics-preview"
className="max-h-96 overflow-auto rounded-md bg-secondary p-3 text-xs"
>
{JSON.stringify(data, null, 2)}
</pre>
) : (
<ActivityIndicator className="size-5" />
)}
</CollapsibleContent>
</Collapsible>
);
}
@@ -23,7 +23,7 @@ import {
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuChevronDown, LuExternalLink, LuX } from "react-icons/lu";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { ConfigFieldMessage } from "../ConfigFieldMessage";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@@ -35,12 +35,12 @@ import {
useNotificationTest,
} from "@/api/ws";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import FilterSwitch from "@/components/filter/FilterSwitch";
@@ -50,7 +50,6 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import { isPWA } from "@/utils/isPWA";
import { isIOS } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
@@ -746,8 +745,6 @@ export function CameraNotificationSwitch({
const isSuspended =
notificationSuspendUntil !== "0" || notificationState === "OFF";
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
if (duration == "off") {
sendNotification("OFF");
@@ -756,10 +753,6 @@ export function CameraNotificationSwitch({
}
};
const handleCustomSuspend = (totalMinutes: number) => {
sendNotificationSuspend(totalMinutes);
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
@@ -819,41 +812,34 @@ export function CameraNotificationSwitch({
</div>
{!isSuspended ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="flex gap-2">
{t("notification.suspendTime.suspend")}
<LuChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleSuspend("5")}>
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("10")}>
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("30")}>
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("60")}>
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("840")}>
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("1440")}>
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("off")}>
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCustomDialogOpen(true)}>
{t("notification.suspendTime.custom")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
@@ -863,12 +849,6 @@ export function CameraNotificationSwitch({
{t("notification.cancelSuspension")}
</Button>
)}
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={handleCustomSuspend}
/>
</div>
);
}
@@ -1,5 +1,4 @@
import type { ComponentType } from "react";
import AnalyticsPreview from "./AnalyticsPreview";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
@@ -57,9 +56,6 @@ export const sectionRenderers: SectionRenderers = {
birdseye: {
BirdseyeCameraReorder,
},
telemetry: {
AnalyticsPreview,
},
};
export default sectionRenderers;
@@ -37,7 +37,6 @@ import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import omit from "lodash/omit";
import isEqual from "lodash/isEqual";
import {
Collapsible,
@@ -55,7 +54,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn, isJsonObject } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
ConfigSectionData,
HiddenFieldEntry,
@@ -126,10 +125,6 @@ export interface SectionConfig {
fieldMessages?: FieldConditionalMessage[];
/** Maps whose key order is saved (e.g. live stream ladder order) */
orderedMaps?: string[];
/** Fields the backend resolves at runtime, ignored when checking for reset */
runtimeFields?: string[];
/** Hide the Reset to Global / Reset to Default button */
hideReset?: boolean;
}
export interface BaseSectionProps {
@@ -200,17 +195,6 @@ export interface CreateSectionOptions {
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
// applySchemaDefaults only descends into objects present in the data, and
// default_factory objects carry no schema default, so keep the nesting.
function objectShape(value: unknown): Record<string, unknown> {
if (!isJsonObject(value)) return {};
return Object.fromEntries(
Object.entries(value)
.filter(([, v]) => isJsonObject(v))
.map(([k, v]) => [k, objectShape(v)]),
);
}
export function ConfigSection({
sectionPath,
defaultConfig,
@@ -508,15 +492,6 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData],
);
// A section at its schema defaults may be absent from the YAML, and
// deleting an absent key fails. Non-default values must come from the YAML.
const globalDiffersFromDefaults = useMemo(() => {
if (!modifiedSchema) return true;
const current = omit(compareBaseData, sectionConfig.runtimeFields ?? []);
const defaults = applySchemaDefaults(modifiedSchema, objectShape(current));
return buildOverrides(current, undefined, defaults) !== undefined;
}, [compareBaseData, modifiedSchema, sectionConfig.runtimeFields]);
const orderedMaps = sectionConfig.orderedMaps;
const buildSectionOverrides = useCallback(
(current: unknown, base: unknown, defaults: unknown) =>
@@ -1158,8 +1133,7 @@ export function ConfigSection({
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) ||
(effectiveLevel === "global" && globalDiffersFromDefaults)) &&
!sectionConfig.hideReset &&
effectiveLevel === "global") &&
!hasChanges &&
!skipSave &&
!profileName && (
@@ -50,7 +50,6 @@ import { use24HourTime } from "@/hooks/use-date-utils";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
type LiveContextMenuProps = {
className?: string;
@@ -233,8 +232,6 @@ export default function LiveContextMenu({
const isSuspended =
notificationSuspendUntil !== "0" || notificationState === "OFF";
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
if (duration === "off") {
sendNotification("OFF");
@@ -535,16 +532,6 @@ export default function LiveContextMenu({
>
{t("time.24hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => setCustomDialogOpen(true)
: undefined
}
>
{t("time.custom", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
@@ -579,12 +566,6 @@ export default function LiveContextMenu({
/>
)}
</Dialog>
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={(minutes) => sendNotificationSuspend(minutes)}
/>
</div>
);
}
@@ -1,12 +1,6 @@
import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Calendar } from "../ui/calendar";
import {
ButtonHTMLAttributes,
ComponentProps,
useEffect,
useMemo,
useRef,
} from "react";
import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react";
import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps } from "react-day-picker";
@@ -184,14 +178,12 @@ type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
disabled?: ComponentProps<typeof Calendar>["disabled"];
recordingsSummary?: RecordingsSummary;
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
disabled,
recordingsSummary,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
@@ -219,14 +211,13 @@ export function TimezoneAwareCalendar({
[timezone],
);
const defaultDisabledDates = useMemo(() => {
const disabledDates = useMemo(() => {
// midday: ranges match by calendar day, so this dodges DST edges
const from = new Date(year, month, day + 1, 12);
const to = new Date(from);
to.setFullYear(from.getFullYear() + 10);
return { from, to };
}, [year, month, day]);
const disabledDates = disabled ?? defaultDisabledDates;
const today = useMemo(
() => new Date(year, month, day, 12),
@@ -1,208 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { isDesktop } from "react-device-detect";
import { FaCalendarAlt } from "react-icons/fa";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
type CustomSuspensionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (minutes: number) => void;
};
const ONE_HOUR_MS = 60 * 60 * 1000;
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
function isValidDate(d: Date): boolean {
return !Number.isNaN(d.getTime());
}
function wallClockShiftMs(date: Date, timezone?: string): number {
if (!timezone) return 0;
try {
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return (
(Math.round(getUTCOffset(date, timezone)) -
Math.round(getUTCOffset(date, localTimezone))) *
60_000
);
} catch {
return 0;
}
}
function toWallClock(instant: Date, timezone?: string): Date {
return new Date(instant.getTime() + wallClockShiftMs(instant, timezone));
}
function fromWallClock(wallClock: Date, timezone?: string): Date {
// offsets are measured at an estimate first, since the wall clock itself can
// sit on the other side of a DST change from the instant it represents
const estimate = new Date(
wallClock.getTime() - wallClockShiftMs(wallClock, timezone),
);
return new Date(wallClock.getTime() - wallClockShiftMs(estimate, timezone));
}
export default function CustomSuspensionDialog({
open,
onOpenChange,
onConfirm,
}: CustomSuspensionDialogProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = config?.ui.timezone;
const [until, setUntil] = useState<Date>(
() => new Date(Date.now() + ONE_HOUR_MS),
);
const [calendarOpen, setCalendarOpen] = useState(false);
useEffect(() => {
if (open) setUntil(new Date(Date.now() + ONE_HOUR_MS));
}, [open]);
const formattedDate = useFormattedTimestamp(
isValidDate(until) ? Math.floor(until.getTime() / 1000) : 0,
t("time.formattedTimestampMonthDayYear.24hour", { ns: "common" }),
timezone,
);
const untilWallClock = useMemo(
() => (isValidDate(until) ? toWallClock(until, timezone) : undefined),
[until, timezone],
);
const startOfTodayWallClock = toWallClock(new Date(), timezone);
startOfTodayWallClock.setHours(0, 0, 0, 0);
const isFuture = isValidDate(until) && until.getTime() > Date.now();
const handleApply = () => {
const remainingMs = until.getTime() - Date.now();
if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
setUntil(new Date(until));
return;
}
onConfirm(Math.ceil(remainingMs / 60_000));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("notification.customSuspension.title")}</DialogTitle>
<DialogDescription>
{t("notification.customSuspension.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>{t("notification.customSuspension.untilLabel")}</Label>
<div className="flex items-center gap-2 rounded-lg bg-secondary p-2 text-secondary-foreground">
<FaCalendarAlt />
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={calendarOpen ? "select" : "default"}
size="sm"
>
{isValidDate(until) ? formattedDate : "—"}
</Button>
</PopoverTrigger>
<PopoverContent
className="flex flex-col items-center"
disablePortal
>
<TimezoneAwareCalendar
timezone={timezone}
selectedDay={untilWallClock}
disabled={{ before: startOfTodayWallClock }}
onSelect={(day) => {
if (!day) return;
const next = new Date(day);
const carry =
untilWallClock ?? toWallClock(new Date(), timezone);
next.setHours(
carry.getHours(),
carry.getMinutes(),
carry.getSeconds(),
0,
);
setUntil(fromWallClock(next, timezone));
setCalendarOpen(false);
}}
/>
</PopoverContent>
</Popover>
<input
className="text-md border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
aria-label={t("notification.customSuspension.untilLabel")}
type="time"
value={
untilWallClock
? `${pad(untilWallClock.getHours())}:${pad(untilWallClock.getMinutes())}`
: ""
}
step="60"
onChange={(e) => {
const [h, m] = e.target.value.split(":");
const hh = Number.parseInt(h ?? "", 10);
const mm = Number.parseInt(m ?? "", 10);
if (Number.isNaN(hh) || Number.isNaN(mm)) return;
const next = new Date(
untilWallClock ?? toWallClock(new Date(), timezone),
);
next.setHours(hh, mm, 0, 0);
setUntil(fromWallClock(next, timezone));
}}
/>
</div>
{!isFuture && (
<p className="text-sm text-danger">
{t("notification.customSuspension.invalidTime")}
</p>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
type="button"
disabled={!isFuture}
onClick={handleApply}
>
{t("button.apply", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -473,7 +473,7 @@ export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
{ key: "review" },
{ key: "audio" },
{ key: "notifications" },
{ key: "live", compareFields: ["transcode", "height", "quality"] },
{ key: "live" },
{ key: "timestamp_style" },
{
key: "audio_transcription",
-1
View File
@@ -662,7 +662,6 @@ export interface FrigateConfig {
};
telemetry: {
analytics: boolean;
network_interfaces: string[];
stats: {
amd_gpu_stats: boolean;