Files
frigate/frigate/test/test_config.py
T
Josh HawkinsandNicolas Mowen 53fb6c8daa Recording fixes (#24072)
* pin genai review frames to the main stream

* retain previews as long as either stream has recordings

* watch sub stream recording health separately from main

* reject record_sub on the same input as record and document the role

* derive recording paths from the cache segment timestamp

Recording paths carry one second of resolution, but since sub stream recording start times are resolved to fractional wall clock, anchored to the cache file mtime and chained to the previous segment's end. A stream cutting segments faster than once a second resolves consecutive segments into the same second, so two rows collide on the unique path index and the batch insert fails. The cache segment name is unique per camera stream and second by construction because ffmpeg names segments with strftime, so the recording path is now built from that timestamp while the row keeps the resolved start time. This also restores the path semantics from before sub stream recording, when start times came straight from the cache filename.

Nothing derives times from recording paths: playback offsets, stream switching, and export all use the row's start time, which is unchanged, and the recordings sync matches files by exact path string.

* keep the rest of a recording batch when one row conflicts

* only publish record_sub status when a sub stream is configured

* don't shadow camera_cfg when publishing empty cache streams

* back off restarts when a recording stream goes stale

* give the shared sub stream grace on any capture thread reset

* include segment details in recording discard warnings
2026-09-12 07:30:04 -06:00

2223 lines
75 KiB
Python

import json
import os
import unittest
from copy import deepcopy
from unittest.mock import patch
import numpy as np
from pydantic import ValidationError
from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.device import build_detector_config, runner_names
from frigate.util.builtin import deep_merge
class TestConfig(unittest.TestCase):
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.plus_model_info = {
"id": "e63b7345cc83a84ed79dedfc99c16616",
"name": "SSDLite Mobiledet",
"description": "Fine tuned model",
"trainDate": "2023-04-28T23:22:01.262Z",
"type": "ssd",
"supportedDetectors": ["cpu", "edgetpu"],
"width": 320,
"height": 320,
"inputShape": "nhwc",
"pixelFormat": "rgb",
"labelMap": {
"0": "amazon",
"1": "car",
"2": "cat",
"3": "deer",
"4": "dog",
"5": "face",
"6": "fedex",
"7": "license_plate",
"8": "package",
"9": "person",
"10": "ups",
},
}
if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
os.makedirs(MODEL_CACHE_DIR)
def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal)
model = frigate_config.primary_model
assert model.scene == SceneEnum.all
assert model.width == 320
assert frigate_config.devices_for_model(model)[0].detector == (
DetectorTypeEnum.cpu
)
@patch("frigate.detectors.detector_config.load_labels")
def test_model_custom_path(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
# needs to be a file that will exist, doesn't matter what
{"path": "/etc/hosts", "width": 512, "devices": ["openvino:GPU"]},
],
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
model = frigate_config.primary_model
assert model.path == "/etc/hosts"
assert model.width == 512
detector_config = build_detector_config(
frigate_config.devices_for_model(model)[0], model
)
assert detector_config.type == DetectorTypeEnum.openvino
assert detector_config.device == "GPU"
assert detector_config.model.path == "/etc/hosts"
@patch("frigate.detectors.detector_config.load_labels")
def test_model_default_paths_per_detector(self, mock_labels):
mock_labels.return_value = {}
for devices, expected in (
(["cpu"], "/cpu_model.tflite"),
(["edgetpu:pci:0"], "/edgetpu_model.tflite"),
(["openvino:CPU"], "/openvino-model/ssdlite_mobilenet_v2.xml"),
):
config = {"models": [{"devices": devices}]}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.primary_model.path == expected
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_picks_model_by_scene(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"scene": "outdoor", "devices": ["cpu"], "width": 320},
{"scene": "indoor", "devices": ["openvino:CPU"], "width": 300},
],
"cameras": {
"back": {
"detect": {"scene": "outdoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
]
},
},
"front": {
"detect": {"scene": "indoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]},
]
},
},
},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.outdoor
assert frigate_config.model_for_camera("front").scene == SceneEnum.indoor
assert frigate_config.model_for_camera("back").width == 320
assert frigate_config.model_for_camera("front").width == 300
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_requires_a_scene_without_a_default(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"scene": "outdoor", "devices": ["cpu"]},
{"scene": "indoor", "devices": ["openvino:CPU"]},
],
}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_without_a_model_falls_back_to_all(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [{"devices": ["cpu"]}],
"cameras": {
"back": {
"detect": {"scene": "outdoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
]
},
},
},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.all
@patch("frigate.detectors.detector_config.load_labels")
def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"devices": ["cpu"], "width": 320},
{"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416},
],
}
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
# runtime camera adds (wizard, clone, debug replay) insert an already
# resolved camera into the shared config without re-running parse
added = deepcopy(self.minimal)
added["cameras"]["new_cam"] = {
"detect": {"height": 1080, "width": 1920, "fps": 5, "scene": "outdoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]},
]
},
}
new_config = FrigateConfig(**(deep_merge(deepcopy(config), added)))
frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"]
assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor
assert frigate_config.model_for_camera("new_cam").width == 416
@patch("frigate.detectors.detector_config.load_labels")
def test_model_for_camera_unknown_camera_uses_default_model(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"devices": ["cpu"], "width": 320},
{"scene": "outdoor", "devices": ["openvino:CPU"], "width": 416},
],
}
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
# a caller racing a runtime remove may still name the popped camera
assert frigate_config.model_for_camera("removed").scene == SceneEnum.all
@patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [{"scene": "indoor", "devices": ["cpu"]}],
"cameras": {
"back": {
"detect": {"scene": "outdoor"},
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
]
},
},
},
}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_models_must_use_unique_scenes(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"scene": "outdoor", "devices": ["cpu"]},
{"scene": "outdoor", "devices": ["openvino:CPU"]},
],
}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_model_devices_must_share_a_detector(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"devices": ["cpu", "openvino:CPU"]}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_model_requires_a_known_detector(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"devices": ["not_a_detector:0"]}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_model_requires_a_device(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"devices": []}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_shareable_devices_may_repeat(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"devices": ["openvino:GPU", "openvino:GPU"]}]}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
devices = frigate_config.devices_for_model(frigate_config.primary_model)
assert runner_names(devices) == ["openvino:GPU", "openvino:GPU#2"]
@patch("frigate.detectors.detector_config.load_labels")
def test_exclusive_devices_may_not_repeat(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"devices": ["edgetpu:pci:0", "edgetpu:pci:0"]}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
def test_invalid_mqtt_config(self):
config = {
"mqtt": {"host": "mqtt", "user": "test"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_inherit_tracked_objects(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {"track": ["person", "dog"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.track
def test_deep_merge_override_replaces_list_values(self):
base = {"objects": {"track": ["person", "face"]}}
update = {"objects": {"track": ["person"]}}
merged = deep_merge(base, update, override=True)
assert merged["objects"]["track"] == ["person"]
def test_deep_merge_merge_lists_still_appends(self):
base = {"track": ["person"]}
update = {"track": ["face"]}
merged = deep_merge(base, update, override=True, merge_lists=True)
assert merged["track"] == ["person", "face"]
def test_override_birdseye(self):
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {"enabled": True, "modes": ["continuous"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"birdseye": {
"enabled": False,
"modes": ["motion"],
},
}
},
}
frigate_config = FrigateConfig(**config)
assert not frigate_config.cameras["back"].birdseye.enabled
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.motion
]
def test_override_birdseye_non_inheritable(self):
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {
"enabled": True,
"modes": ["continuous"],
"height": 1920,
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].birdseye.enabled
def test_inherit_birdseye(self):
config = {
**self.minimal,
"birdseye": {"enabled": True, "modes": ["continuous"]},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].birdseye.enabled
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.continuous
]
def test_camera_modes_replace_the_global_list(self):
"""A camera list fully replaces the global one, it does not merge into it."""
config = {
**self.minimal,
"birdseye": {"modes": ["motion", "all_objects"]},
}
config["cameras"]["back"]["birdseye"] = {"modes": ["alerts"]}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].birdseye.modes == [
BirdseyeModeEnum.alerts
]
def test_camera_can_select_no_modes(self):
config = {
**self.minimal,
"birdseye": {"modes": ["motion"]},
}
config["cameras"]["back"]["birdseye"] = {"modes": []}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].birdseye.modes == []
def test_override_tracked_objects(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {"track": ["person", "dog"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {"track": ["cat"]},
}
},
}
frigate_config = FrigateConfig(**config)
assert "cat" in frigate_config.cameras["back"].objects.track
def test_default_object_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {"track": ["person", "dog"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.filters
def test_default_audio_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"audio": {"listen": ["speech", "yell"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
"speech",
"yell",
}
def test_override_audio_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"audio": {
"listen": ["speech", "yell"],
"filters": {"speech": {"threshold": 0.9}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "speech" in frigate_config.cameras["back"].audio.filters
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
assert "yell" in frigate_config.cameras["back"].audio.filters
assert "babbling" not in frigate_config.cameras["back"].audio.filters
def test_inherit_object_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"threshold": 0.7}},
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.filters
assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7
def test_override_object_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"threshold": 0.7}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.filters
assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7
def test_unsupported_tracked_object_pruned_from_track_and_filters(self):
# "unicorn" is not in the model labelmap, so it must be removed from the
# tracked objects AND from the object filters, otherwise a stale filter
# entry lingers in the parsed config.
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"track": ["person", "unicorn"],
"filters": {
"person": {"threshold": 0.7},
"unicorn": {"threshold": 0.7},
},
},
}
},
}
frigate_config = FrigateConfig(**config)
objects = frigate_config.cameras["back"].objects
assert "unicorn" not in objects.track
assert "unicorn" not in objects.filters
# supported entries are left untouched
assert "person" in objects.track
assert "person" in objects.filters
def test_global_object_mask(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {"track": ["person", "dog"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"mask": {
"global_mask_1": {
"friendly_name": "Global Mask 1",
"enabled": True,
"coordinates": "0,0,1,1,0,1",
}
},
"filters": {
"dog": {
"mask": {
"dog_mask_1": {
"friendly_name": "Dog Mask 1",
"enabled": True,
"coordinates": "1,1,1,1,1,1",
}
}
}
},
},
}
},
}
frigate_config = FrigateConfig(**config)
back_camera = frigate_config.cameras["back"]
assert "dog" in back_camera.objects.filters
# dog filter has its own mask + global mask merged
assert len(back_camera.objects.filters["dog"].mask) == 2
# person filter only has the global mask
assert len(back_camera.objects.filters["person"].mask) == 1
def test_motion_mask_relative_matches_explicit(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {"alerts": {"retain": {"days": 20}}},
"cameras": {
"explicit": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"motion": {
"mask": {
"explicit_mask": {
"friendly_name": "Explicit Mask",
"enabled": True,
"coordinates": "0,0,200,100,600,300,800,400",
}
}
},
},
"relative": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"motion": {
"mask": {
"relative_mask": {
"friendly_name": "Relative Mask",
"enabled": True,
"coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
}
}
},
},
},
}
frigate_config = FrigateConfig(**config)
assert np.array_equal(
frigate_config.cameras["explicit"].motion.rasterized_mask,
frigate_config.cameras["relative"].motion.rasterized_mask,
)
def test_default_input_args(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
def test_ffmpeg_params_global(self):
config = {
"ffmpeg": {"input_args": "-re"},
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"threshold": 0.7}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
def test_ffmpeg_params_camera(self):
config = {
"mqtt": {"host": "mqtt"},
"ffmpeg": {"input_args": ["test"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
],
"input_args": ["-re"],
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"threshold": 0.7}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
assert "test" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
def test_ffmpeg_params_input(self):
config = {
"mqtt": {"host": "mqtt"},
"ffmpeg": {"input_args": ["test2"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
"input_args": "-re test",
}
],
"input_args": "test3",
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"threshold": 0.7}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "-re" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
assert "test" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
assert "test2" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
assert "test3" not in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
def test_inherit_clips_retention(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {"alerts": {"retain": {"days": 20}}},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].record.alerts.retain.days == 20
def test_roles_listed_twice_throws_error(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]},
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["detect"]},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_zone_matching_camera_name_throws_error(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {"back": {"coordinates": "1,1,1,1,1,1"}},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_zone_assigns_color_and_contour(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {"test": {"coordinates": "1,1,1,1,1,1"}},
}
},
}
frigate_config = FrigateConfig(**config)
assert isinstance(
frigate_config.cameras["back"].zones["test"].contour, np.ndarray
)
assert frigate_config.cameras["back"].zones["test"].color != (0, 0, 0)
def test_zone_filter_area_percent_converts_to_pixels(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {
"notification": {
"coordinates": "0.03,1,0.025,0,0.626,0,0.643,1",
"objects": ["person"],
"filters": {"person": {"min_area": 0.1}},
}
},
}
},
}
frigate_config = FrigateConfig(**config)
expected_min_area = int(1080 * 1920 * 0.1)
assert (
frigate_config.cameras["back"]
.zones["notification"]
.filters["person"]
.min_area
== expected_min_area
)
def test_zone_relative_matches_explicit(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {
"alerts": {
"retain": {
"days": 20,
}
}
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 400,
"width": 800,
"fps": 5,
},
"zones": {
"explicit": {
"coordinates": "0,0,200,100,600,300,800,400",
},
"relative": {
"coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0",
},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert np.array_equal(
frigate_config.cameras["back"].zones["explicit"].contour,
frigate_config.cameras["back"].zones["relative"].contour,
)
def test_role_assigned_but_not_enabled(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
{"path": "rtsp://10.0.0.1:554/record", "roles": ["record"]},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
ffmpeg_cmds = frigate_config.cameras["back"].ffmpeg_cmds
assert len(ffmpeg_cmds) == 1
assert "clips" not in ffmpeg_cmds[0]["roles"]
def test_record_sub_cmd_writes_sub_cache_path(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["record_sub"],
},
]
},
"record": {"enabled": True, "sub": {"enabled": True}},
}
},
}
frigate_config = FrigateConfig(**config)
cmds = frigate_config.cameras["back"].ffmpeg_cmds
sub_cmds = [c for c in cmds if "record_sub" in c["roles"]]
assert len(sub_cmds) == 1
joined = " ".join(sub_cmds[0]["cmd"])
assert "back@sub@" in joined
def test_record_sub_disabled_no_sub_cache_path(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record"],
},
]
},
"record": {"enabled": True, "sub": {"enabled": False}},
}
},
}
frigate_config = FrigateConfig(**config)
cmds = frigate_config.cameras["back"].ffmpeg_cmds
assert all("@sub@" not in " ".join(c["cmd"]) for c in cmds)
def _sub_record_config(self, ffmpeg_extra: dict | None = None) -> dict:
return {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["record_sub"],
},
],
**(ffmpeg_extra or {}),
},
"record": {"enabled": True, "sub": {"enabled": True}},
}
},
}
def _sub_record_cmd(self, config: dict) -> str:
cmds = FrigateConfig(**config).cameras["back"].ffmpeg_cmds
sub_cmds = [c for c in cmds if "record_sub" in c["roles"]]
assert len(sub_cmds) == 1
return " ".join(sub_cmds[0]["cmd"])
def test_record_sub_output_args_inherit_record(self):
config = self._sub_record_config(
{"output_args": {"record": "preset-record-generic-audio-copy"}}
)
cmd = self._sub_record_cmd(config)
# the customized record args, not the stock aac default
assert "-c copy" in cmd
assert "-c:a aac" not in cmd
def test_record_sub_output_args_override_record(self):
config = self._sub_record_config(
{
"output_args": {
"record": "preset-record-generic-audio-aac",
"record_sub": "preset-record-generic",
}
}
)
cmd = self._sub_record_cmd(config)
assert "-c copy -an" in cmd
assert "-c:a aac" not in cmd
def test_record_output_args_unaffected_by_record_sub(self):
config = self._sub_record_config(
{
"output_args": {
"record": "preset-record-generic-audio-aac",
"record_sub": "preset-record-generic",
}
}
)
cmds = FrigateConfig(**config).cameras["back"].ffmpeg_cmds
record_cmd = " ".join(next(c for c in cmds if "record" in c["roles"])["cmd"])
assert "-c:a aac" in record_cmd
def test_record_sub_manual_output_args(self):
config = self._sub_record_config(
{
"output_args": {
"record_sub": "-f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c:v copy -c:a aac -ar 16000"
}
}
)
assert "-ar 16000" in self._sub_record_cmd(config)
def test_fails_on_bad_record_sub_segment_time(self):
config = self._sub_record_config(
{
"output_args": {
"record_sub": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an"
}
}
)
self.assertRaisesRegex(
ValueError,
"segment_time",
lambda: FrigateConfig(**config).cameras,
)
def test_fails_on_record_and_record_sub_on_same_input(self):
config = self._sub_record_config()
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record", "record_sub"],
},
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["audio"]},
]
self.assertRaisesRegex(
ValueError,
"record and record_sub assigned to the same input",
lambda: FrigateConfig(**config).cameras,
)
def test_fails_on_record_sub_with_a_single_input(self):
# the single input case has record forced onto it, so record_sub can
# only ever duplicate that same stream
config = self._sub_record_config()
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record_sub"]},
]
self.assertRaisesRegex(
ValueError,
"record and record_sub assigned to the same input",
lambda: FrigateConfig(**config).cameras,
)
def test_record_sub_segment_time_not_checked_when_disabled(self):
config = self._sub_record_config(
{
"output_args": {
"record_sub": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an"
}
}
)
config["cameras"]["back"]["record"]["sub"]["enabled"] = False
FrigateConfig(**config).cameras
def test_max_disappeared_default(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"enabled": True,
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].detect.max_disappeared == 5 * 5
def test_motion_frame_height_wont_go_below_120(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].motion.frame_height == 100
def test_motion_contour_area_dynamic(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert round(frigate_config.cameras["back"].motion.contour_area) == 10
def test_merge_labelmap(self):
config = {
"mqtt": {"host": "mqtt"},
"models": [{"labelmap": {7: "truck"}, "devices": ["cpu"]}],
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[7] == "truck"
def test_audio_labelmap_inheritance_is_separate_from_model_labelmap(self):
config = deep_merge(
{
"audio": {"labelmap": {69: "dogs", 70: "dogs"}},
"cameras": {
"back": {
"audio": {"labelmap": {75: "dogs"}},
}
},
},
self.minimal,
)
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].audio.labelmap == {
69: "dogs",
70: "dogs",
75: "dogs",
}
assert frigate_config.primary_model.merged_labelmap[69] != "dogs"
def test_default_labelmap_empty(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "person"
def test_default_labelmap(self):
config = {
"mqtt": {"host": "mqtt"},
"models": [{"width": 320, "height": 320, "devices": ["cpu"]}],
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "person"
def test_plus_labelmap(self):
with open(os.path.join(MODEL_CACHE_DIR, "test"), "w") as f:
json.dump(self.plus_model_info, f)
with open(os.path.join(MODEL_CACHE_DIR, "test.json"), "w") as f:
json.dump(self.plus_model_info, f)
config = {
"mqtt": {"host": "mqtt"},
"models": [{"path": "plus://test", "devices": ["cpu"]}],
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.primary_model.merged_labelmap[0] == "amazon"
def test_fails_on_invalid_role(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["clips"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_fails_on_missing_role(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["record"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"audio": {"enabled": True},
}
},
}
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
def test_record_sub_config_defaults(self):
config = FrigateConfig(**self.minimal)
record = config.cameras["back"].record
assert record.sub.enabled is False
assert record.sub.continuous.days == 0
assert record.sub.alerts.mode == RetainModeEnum.motion
def test_record_sub_enabled_requires_role(self):
config = deepcopy(self.minimal)
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record"]},
]
config["cameras"]["back"]["record"] = {
"enabled": True,
"sub": {"enabled": True},
}
# no record_sub role assigned -> must raise
self.assertRaisesRegex(
ValueError,
"record_sub is not assigned",
lambda: FrigateConfig(**config),
)
def test_record_sub_role_accepted(self):
config = deepcopy(self.minimal)
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record"]},
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["record_sub"]},
]
config["cameras"]["back"]["record"] = {
"enabled": True,
"sub": {"enabled": True, "continuous": {"days": 30}},
}
parsed = FrigateConfig(**config)
assert parsed.cameras["back"].record.sub.continuous.days == 30
def test_works_on_missing_role_multiple_cams(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["record"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
},
"cam2": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["record"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
},
},
}
FrigateConfig(**config)
def test_global_detect(self):
config = {
"mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].detect.max_disappeared == 1
assert frigate_config.cameras["back"].detect.height == 1080
def test_default_detect(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 720,
"width": 1280,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].detect.max_disappeared == 25
assert frigate_config.cameras["back"].detect.height == 720
def test_global_detect_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1, "height": 720, "width": 1280},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].detect.max_disappeared == 1
assert frigate_config.cameras["back"].detect.height == 1080
assert frigate_config.cameras["back"].detect.width == 1920
def test_global_snapshots(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"enabled": True},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"snapshots": {
"height": 100,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].snapshots.enabled
assert frigate_config.cameras["back"].snapshots.height == 100
def test_default_snapshots(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].snapshots.bounding_box
assert frigate_config.cameras["back"].snapshots.quality == 60
def test_global_snapshots_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"bounding_box": False, "height": 300},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"snapshots": {
"height": 150,
"enabled": True,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].snapshots.bounding_box is False
assert frigate_config.cameras["back"].snapshots.height == 150
assert frigate_config.cameras["back"].snapshots.enabled
def test_global_jsmpeg(self):
config = {
"mqtt": {"host": "mqtt"},
"live": {"quality": 4},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].live.quality == 4
def test_default_live(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].live.quality == 8
def test_global_live_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"live": {"quality": 4, "height": 480},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"live": {
"quality": 7,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].live.quality == 7
assert frigate_config.cameras["back"].live.height == 480
def test_global_timestamp_style(self):
config = {
"mqtt": {"host": "mqtt"},
"timestamp_style": {"position": "bl"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].timestamp_style.position == "bl"
def test_default_timestamp_style(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].timestamp_style.position == "tl"
def test_global_timestamp_style_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"timestamp_style": {"position": "br", "thickness": 2},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"timestamp_style": {"position": "bl", "thickness": 4},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].timestamp_style.position == "bl"
assert frigate_config.cameras["back"].timestamp_style.thickness == 4
def test_allow_retain_to_be_a_decimal(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"retain": {"default": 1.5}},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].snapshots.retain.default == 1.5
def test_fails_on_bad_camera_name(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"retain": {"default": 1.5}},
"cameras": {
"back camer#": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config).cameras)
def test_fails_on_bad_segment_time(self):
config = {
"mqtt": {"host": "mqtt"},
"record": {"enabled": True},
"cameras": {
"back": {
"ffmpeg": {
"output_args": {
"record": "-f segment -segment_time 70 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an"
},
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
],
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(
ValueError,
lambda: FrigateConfig(**config).ffmpeg.output_args.record,
)
def test_fails_zone_defines_untracked_object(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {"track": ["person"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"zones": {
"steps": {
"coordinates": "0,0,0,0",
"objects": ["car", "person"],
},
},
}
},
}
self.assertRaises(ValueError, lambda: FrigateConfig(**config).cameras)
def test_fails_duplicate_keys(self):
raw_config = """
cameras:
test:
ffmpeg:
inputs:
- one
- two
inputs:
- three
- four
"""
self.assertRaises(
DuplicateKeyError, lambda: FrigateConfig.parse_yaml(raw_config)
)
def test_object_filter_ratios_work(self):
config = {
"mqtt": {"host": "mqtt"},
"objects": {
"track": ["person", "dog"],
"filters": {"dog": {"min_ratio": 0.2, "max_ratio": 10.1}},
},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.filters
assert frigate_config.cameras["back"].objects.filters["dog"].min_ratio == 0.2
assert frigate_config.cameras["back"].objects.filters["dog"].max_ratio == 10.1
def test_valid_movement_weights(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"onvif": {
"autotracking": {
"movement_weights": "0, 1, 1.23, 2.34, 0.50, 1"
}
},
}
},
}
frigate_config = FrigateConfig(**config)
assert frigate_config.cameras["back"].onvif.autotracking.movement_weights == [
"0.0",
"1.0",
"1.23",
"2.34",
"0.5",
"1.0",
]
def test_fails_invalid_movement_weights(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"onvif": {"autotracking": {"movement_weights": "1.234, 2.345a"}},
}
},
}
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
class TestAttributeFilterDefaults(unittest.TestCase):
"""Verify attribute filter min_score handling at config load."""
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
config = deep_merge({}, self.minimal)
if object_filters is not None:
config.setdefault("objects", {})["filters"] = object_filters
return FrigateConfig(**config)
def test_attribute_with_no_filter_gets_default_min_score(self):
"""Attribute with no user-provided filter gets created with min_score=0.7."""
config = self._build_config()
face_filter = config.objects.filters.get("face")
self.assertIsNotNone(face_filter)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_without_min_score_gets_bumped(self):
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
config = self._build_config({"face": {"min_area": 500}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_area, 500)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
config = self._build_config({"face": {"min_score": 0.5}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.5)
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
"""Sanity: explicit non-0.5 values pass through unchanged."""
config = self._build_config({"face": {"min_score": 0.3}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.3)
if __name__ == "__main__":
unittest.main(verbosity=2)