mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-05 21:30:30 +03:00
Compare commits
4
Commits
dev
...
3a07751a37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a07751a37 | ||
|
|
1a8062ad34 | ||
|
|
c89ce88afc | ||
|
|
4d52a3d40d |
@@ -1,10 +1,14 @@
|
|||||||
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
||||||
|
|
||||||
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
|
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
|
||||||
frontend converts the Object Detection API frozen graph natively; the four TF
|
frontend translates the Object Detection API pre and post processors literally,
|
||||||
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
|
producing per-class NonMaxSuppression, NonZero ops and map loops with data
|
||||||
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
|
dependent shapes that the GPU plugin handles very badly. Both are cut out the
|
||||||
BGR to match the legacy reverse_input_channels behavior.
|
way ssd_v2_support.json used to do it: the preprocessor is an identity at the
|
||||||
|
native 300x300 input, and the postprocessor becomes a single fused
|
||||||
|
DetectionOutput. The result is the [1, 1, 100, 7] tensor that Frigate's
|
||||||
|
OpenVINO detector expects, with the input flipped to BGR to match the legacy
|
||||||
|
reverse_input_channels behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -12,31 +16,91 @@ import openvino as ov
|
|||||||
from openvino import opset8 as ops
|
from openvino import opset8 as ops
|
||||||
from openvino.preprocess import PrePostProcessor
|
from openvino.preprocess import PrePostProcessor
|
||||||
|
|
||||||
|
MODEL_DIR = "/models/ssdlite_mobilenet_v2_coco_2018_05_09"
|
||||||
|
OUTPUT_PATH = "/models/ssdlite_mobilenet_v2.xml"
|
||||||
|
INPUT_SHAPE = [1, 300, 300, 3]
|
||||||
|
|
||||||
|
# faster_rcnn_box_coder divides the deltas by pipeline.config's y/x/height/width
|
||||||
|
# scales of 10/10/5/5, which DetectionOutput expresses as per-prior variances.
|
||||||
|
BOX_VARIANCES = np.float32([0.1, 0.1, 0.2, 0.2])
|
||||||
|
|
||||||
model = ov.convert_model(
|
model = ov.convert_model(
|
||||||
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
|
f"{MODEL_DIR}/frozen_inference_graph.pb",
|
||||||
input=[("image_tensor:0", [1, 300, 300, 3])],
|
input=[("image_tensor:0", INPUT_SHAPE)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
|
nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()}
|
||||||
boxes = model.output("detection_boxes:0").get_node().input_value(0)
|
parameter = model.get_parameters()[0]
|
||||||
classes = model.output("detection_classes:0").get_node().input_value(0)
|
|
||||||
scores = model.output("detection_scores:0").get_node().input_value(0)
|
|
||||||
|
|
||||||
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
|
preprocessor = nodes["Preprocessor/map/TensorArrayStack/TensorArrayGatherV3"]
|
||||||
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
|
box_deltas = nodes["Postprocessor/Reshape_1"].output(0)
|
||||||
classes = ops.unsqueeze(classes, 2)
|
class_scores = nodes["Postprocessor/convert_scores"].output(0)
|
||||||
scores = ops.unsqueeze(scores, 2)
|
anchors_output = nodes["Postprocessor/Reshape"].output(0)
|
||||||
image_id = ops.multiply(scores, np.float32(0.0))
|
|
||||||
|
|
||||||
detections = ops.concat([image_id, classes, scores, boxes], 2)
|
# The anchors only depend on the static input shape, so fold them into a
|
||||||
detections = ops.unsqueeze(detections, 1)
|
# constant and drop the generator subgraph with the rest of the postprocessor.
|
||||||
|
probe = ov.Core().compile_model(
|
||||||
|
ov.Model([anchors_output, preprocessor.output(0)], [parameter], "probe"), "CPU"
|
||||||
|
)
|
||||||
|
probe_input = np.random.default_rng(0).integers(0, 255, INPUT_SHAPE, dtype=np.uint8)
|
||||||
|
anchors, resized = (out.copy() for out in probe([probe_input]).values())
|
||||||
|
|
||||||
|
assert np.allclose(resized, probe_input, atol=1e-3), (
|
||||||
|
"preprocessor is not an identity at 300x300, it cannot be bypassed"
|
||||||
|
)
|
||||||
|
|
||||||
|
image = ops.convert(parameter, "f32")
|
||||||
|
|
||||||
|
for consumer in list(preprocessor.output(0).get_target_inputs()):
|
||||||
|
consumer.replace_source_output(image.output(0))
|
||||||
|
|
||||||
|
# (ymin, xmin, ymax, xmax) -> (xmin, ymin, xmax, ymax)
|
||||||
|
priors = anchors[:, [1, 0, 3, 2]].astype(np.float32).reshape(-1)
|
||||||
|
variances = np.tile(BOX_VARIANCES, len(anchors))
|
||||||
|
proposals = ops.constant(np.stack([priors, variances])[np.newaxis])
|
||||||
|
|
||||||
|
# (ty, tx, th, tw) -> (dx, dy, dw, dh) for the CENTER_SIZE decode
|
||||||
|
box_logits = ops.reshape(ops.gather(box_deltas, [1, 0, 3, 2], 1), [1, -1], False)
|
||||||
|
class_preds = ops.reshape(class_scores, [1, -1], False)
|
||||||
|
|
||||||
|
detections = ops.detection_output(
|
||||||
|
box_logits,
|
||||||
|
class_preds,
|
||||||
|
proposals,
|
||||||
|
{
|
||||||
|
"background_label_id": 0,
|
||||||
|
"top_k": 100,
|
||||||
|
"keep_top_k": [100],
|
||||||
|
"nms_threshold": 0.6,
|
||||||
|
"confidence_threshold": 0.3,
|
||||||
|
"code_type": "caffe.PriorBoxParameter.CENTER_SIZE",
|
||||||
|
"share_location": True,
|
||||||
|
"variance_encoded_in_target": False,
|
||||||
|
"normalized": True,
|
||||||
|
"clip_before_nms": False,
|
||||||
|
"clip_after_nms": True,
|
||||||
|
"decrease_label_id": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
detections.output(0).get_tensor().set_names({"detection_out"})
|
detections.output(0).get_tensor().set_names({"detection_out"})
|
||||||
|
|
||||||
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
|
model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2")
|
||||||
|
|
||||||
ppp = PrePostProcessor(model)
|
ppp = PrePostProcessor(model)
|
||||||
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
|
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
|
||||||
ppp.input().preprocess().reverse_channels()
|
ppp.input().preprocess().reverse_channels()
|
||||||
model = ppp.build()
|
model = ppp.build()
|
||||||
|
|
||||||
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
|
# Fail the build rather than silently ship the dynamically shaped graph again.
|
||||||
|
op_types = [op.get_type_name() for op in model.get_ordered_ops()]
|
||||||
|
assert op_types.count("DetectionOutput") == 1, "postprocessor was not fused"
|
||||||
|
|
||||||
|
for dynamic_op in ("NonMaxSuppression", "NonZero", "Loop", "TensorIterator"):
|
||||||
|
assert dynamic_op not in op_types, f"{dynamic_op} left in the graph"
|
||||||
|
|
||||||
|
output_shape = model.outputs[0].get_partial_shape()
|
||||||
|
assert output_shape.is_static and list(output_shape) == [1, 1, 100, 7], (
|
||||||
|
f"unexpected detector output shape {output_shape}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ov.save_model(model, OUTPUT_PATH, compress_to_fp16=True)
|
||||||
|
|||||||
+15
-1
@@ -31,7 +31,10 @@ from frigate.api.auth import (
|
|||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from frigate.api.config_util import swap_runtime_config
|
from frigate.api.config_util import (
|
||||||
|
publish_camera_section_updates,
|
||||||
|
swap_runtime_config,
|
||||||
|
)
|
||||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
from frigate.api.defs.request.app_body import (
|
from frigate.api.defs.request.app_body import (
|
||||||
AppConfigSetBody,
|
AppConfigSetBody,
|
||||||
@@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
body.update_topic, settings
|
body.update_topic, settings
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# a config/cameras/* topic publishes camera copies, a
|
||||||
|
# global topic the global object. FrigateConfig.parse
|
||||||
|
# folds some global sections down into every camera,
|
||||||
|
# and workers read both objects, so any such section
|
||||||
|
# needs its camera copies sent alongside the global
|
||||||
|
# publish above.
|
||||||
|
if body.update_topic == "config/birdseye":
|
||||||
|
publish_camera_section_updates(
|
||||||
|
request.app, config, CameraConfigUpdateEnum.birdseye
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content=(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,30 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdateTopic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_camera_section_updates(
|
||||||
|
app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast every camera's re-resolved value for a global section.
|
||||||
|
|
||||||
|
Global sections are folded into each camera at parse time and the camera
|
||||||
|
copies are what workers read, so send them rather than leave a worker to
|
||||||
|
guess which cameras were inheriting.
|
||||||
|
"""
|
||||||
|
for camera_name, camera_config in config.cameras.items():
|
||||||
|
settings = getattr(camera_config, update_type.name, None)
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(update_type, camera_name), settings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||||
@@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
|||||||
camera the user turned off would silently come back on.
|
camera the user turned off would silently come back on.
|
||||||
"""
|
"""
|
||||||
app.frigate_config = config
|
app.frigate_config = config
|
||||||
|
|
||||||
|
if app.config_holder is not None:
|
||||||
|
app.config_holder.set(config)
|
||||||
|
|
||||||
app.genai_manager.update_config(config)
|
app.genai_manager.update_config(config)
|
||||||
|
|
||||||
if app.profile_manager is not None:
|
if app.profile_manager is not None:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
from frigate.config.profile_manager import ProfileManager
|
from frigate.config.profile_manager import ProfileManager
|
||||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
@@ -74,6 +75,7 @@ def create_fastapi_app(
|
|||||||
dispatcher: Dispatcher | None = None,
|
dispatcher: Dispatcher | None = None,
|
||||||
profile_manager: ProfileManager | None = None,
|
profile_manager: ProfileManager | None = None,
|
||||||
enforce_default_admin: bool = True,
|
enforce_default_admin: bool = True,
|
||||||
|
config_holder: ConfigHolder | None = None,
|
||||||
):
|
):
|
||||||
logger.info("Starting FastAPI app")
|
logger.info("Starting FastAPI app")
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -162,6 +164,7 @@ def create_fastapi_app(
|
|||||||
app.replay_manager = replay_manager
|
app.replay_manager = replay_manager
|
||||||
app.dispatcher = dispatcher
|
app.dispatcher = dispatcher
|
||||||
app.profile_manager = profile_manager
|
app.profile_manager = profile_manager
|
||||||
|
app.config_holder = config_holder
|
||||||
|
|
||||||
if frigate_config.auth.enabled:
|
if frigate_config.auth.enabled:
|
||||||
secret = get_jwt_secret()
|
secret = get_jwt_secret()
|
||||||
|
|||||||
+14
-1
@@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
|
|||||||
from frigate.comms.zmq_proxy import ZmqProxy
|
from frigate.comms.zmq_proxy import ZmqProxy
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
from frigate.config.config import FrigateConfig
|
from frigate.config.config import FrigateConfig
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
from frigate.config.profile_manager import ProfileManager
|
from frigate.config.profile_manager import ProfileManager
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
@@ -122,7 +123,18 @@ class FrigateApp:
|
|||||||
self.processes: dict[str, int] = {}
|
self.processes: dict[str, int] = {}
|
||||||
self.embeddings: EmbeddingsContext | None = None
|
self.embeddings: EmbeddingsContext | None = None
|
||||||
self.profile_manager: ProfileManager | None = None
|
self.profile_manager: ProfileManager | None = None
|
||||||
self.config = config
|
self.config_holder = ConfigHolder(config)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> FrigateConfig:
|
||||||
|
"""The current config, not the one Frigate booted with.
|
||||||
|
|
||||||
|
Read through the holder so the deferred watchdog factories below build
|
||||||
|
a replacement process from the config as it is now. There is no setter
|
||||||
|
on purpose: a plain attribute would let a caller pin this back to a
|
||||||
|
single object and reintroduce the staleness.
|
||||||
|
"""
|
||||||
|
return self.config_holder.config
|
||||||
|
|
||||||
def ensure_dirs(self) -> None:
|
def ensure_dirs(self) -> None:
|
||||||
dirs = [
|
dirs = [
|
||||||
@@ -645,6 +657,7 @@ class FrigateApp:
|
|||||||
self.replay_manager,
|
self.replay_manager,
|
||||||
self.dispatcher,
|
self.dispatcher,
|
||||||
self.profile_manager,
|
self.profile_manager,
|
||||||
|
config_holder=self.config_holder,
|
||||||
),
|
),
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=5001,
|
port=5001,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Shared handle on the config object that is current for this instance."""
|
||||||
|
|
||||||
|
from .config import FrigateConfig
|
||||||
|
|
||||||
|
__all__ = ["ConfigHolder"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigHolder:
|
||||||
|
"""Indirection for the most recently parsed config.
|
||||||
|
|
||||||
|
/api/config/set re-parses yaml into a brand new FrigateConfig instead of
|
||||||
|
mutating the old one, so any reference captured during startup goes stale
|
||||||
|
the first time a user saves. Anything that has to build something after
|
||||||
|
startup, most importantly the watchdog factories that rebuild a crashed
|
||||||
|
process, must read through a holder rather than close over a config
|
||||||
|
object, or the rebuilt process comes back with the config as it was at
|
||||||
|
boot and silently discards every change made since.
|
||||||
|
|
||||||
|
There is deliberately no setter on the read side: the swap runs in exactly
|
||||||
|
one place (frigate.api.config_util.swap_runtime_config) and everyone else
|
||||||
|
only reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: FrigateConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> FrigateConfig:
|
||||||
|
"""The config as of the most recent successful save."""
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def set(self, config: FrigateConfig) -> None:
|
||||||
|
"""Install a freshly parsed config as the current one."""
|
||||||
|
self._config = config
|
||||||
@@ -23,6 +23,7 @@ from frigate.genai.prompts import (
|
|||||||
build_review_summary_prompt,
|
build_review_summary_prompt,
|
||||||
)
|
)
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
|
from frigate.util.builtin import has_non_finite_number
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -164,6 +165,15 @@ class GenAIClient:
|
|||||||
except json.JSONDecodeError as je:
|
except json.JSONDecodeError as je:
|
||||||
logger.error("Failed to parse review description JSON: %s", je)
|
logger.error("Failed to parse review description JSON: %s", je)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# model_construct skips validation, so non-finite numbers that
|
||||||
|
# the validated path would have rejected have to be caught here
|
||||||
|
if has_non_finite_number(raw):
|
||||||
|
logger.error(
|
||||||
|
"Discarding review description containing non-finite numbers."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# observations and confidence are required on the model; fill an empty default
|
# observations and confidence are required on the model; fill an empty default
|
||||||
# if the response omitted it so attribute access stays safe.
|
# if the response omitted it so attribute access stays safe.
|
||||||
raw.setdefault("observations", [])
|
raw.setdefault("observations", [])
|
||||||
|
|||||||
@@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if update_topic is not None and birdseye_config is not None:
|
if update_topic is not None and birdseye_config is not None:
|
||||||
previous_global_mode = self.config.birdseye.mode
|
# only the global-only fields are applied here; the per-camera
|
||||||
|
# enabled and mode arrive on config/cameras/<name>/birdseye,
|
||||||
|
# already resolved against yaml by the config parse
|
||||||
self.config.birdseye = birdseye_config
|
self.config.birdseye = birdseye_config
|
||||||
|
|
||||||
for camera_config in self.config.cameras.values():
|
|
||||||
if camera_config.birdseye.mode == previous_global_mode:
|
|
||||||
camera_config.birdseye.mode = birdseye_config.mode
|
|
||||||
|
|
||||||
logger.debug("Applied dynamic birdseye config update")
|
logger.debug("Applied dynamic birdseye config update")
|
||||||
|
|
||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
@@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_global_birdseye_save_fans_out_resolved_camera_configs(
|
||||||
|
self, mock_find_config
|
||||||
|
):
|
||||||
|
"""A global birdseye save must also publish the per-camera values.
|
||||||
|
|
||||||
|
Global birdseye only seeds enabled and mode; the camera copies are what
|
||||||
|
the output process actually reads. Sending just the global object makes
|
||||||
|
a worker guess which cameras were inheriting, and the only available
|
||||||
|
guess (mode still equals the previous global) wrongly claims a camera
|
||||||
|
whose explicit yaml mode happens to match.
|
||||||
|
"""
|
||||||
|
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
|
||||||
|
# explicit override that matches the global value being replaced
|
||||||
|
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
|
||||||
|
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
app, mock_publisher = self._create_app_with_publisher()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"birdseye": {"mode": "continuous"}},
|
||||||
|
"update_topic": "config/birdseye",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
# the global object still goes out on its own topic
|
||||||
|
mock_publisher.publisher.publish.assert_called_once()
|
||||||
|
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
||||||
|
self.assertEqual(topic, "config/birdseye")
|
||||||
|
self.assertEqual(settings.mode.value, "continuous")
|
||||||
|
|
||||||
|
published = {
|
||||||
|
call[0][0].camera: call[0][1]
|
||||||
|
for call in mock_publisher.publish_update.call_args_list
|
||||||
|
}
|
||||||
|
self.assertEqual(set(published), {"front_door", "back_yard"})
|
||||||
|
|
||||||
|
for call in mock_publisher.publish_update.call_args_list:
|
||||||
|
self.assertEqual(
|
||||||
|
call[0][0].update_type, CameraConfigUpdateEnum.birdseye
|
||||||
|
)
|
||||||
|
|
||||||
|
# the override survives, the inheriting camera follows global
|
||||||
|
self.assertEqual(published["front_door"].mode.value, "motion")
|
||||||
|
self.assertEqual(published["back_yard"].mode.value, "continuous")
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch("frigate.api.app.find_config_file")
|
||||||
|
def test_save_updates_the_config_holder(self, mock_find_config):
|
||||||
|
"""A save must move the holder onto the freshly parsed config.
|
||||||
|
|
||||||
|
FrigateApp reads the holder when the watchdog rebuilds a crashed
|
||||||
|
process; if the save leaves it on the boot config, that process comes
|
||||||
|
back having lost every change made since Frigate started.
|
||||||
|
"""
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
|
|
||||||
|
config_path = self._write_config_file()
|
||||||
|
mock_find_config.return_value = config_path
|
||||||
|
|
||||||
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
|
mock_publisher.publisher = MagicMock()
|
||||||
|
boot_config = FrigateConfig(**self.minimal_config)
|
||||||
|
holder = ConfigHolder(boot_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = create_fastapi_app(
|
||||||
|
boot_config,
|
||||||
|
self.db,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
mock_publisher,
|
||||||
|
None,
|
||||||
|
enforce_default_admin=False,
|
||||||
|
config_holder=holder,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mock_get_current_user(request: Request):
|
||||||
|
return {"username": "admin", "role": "admin"}
|
||||||
|
|
||||||
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
|
return list(self.minimal_config.get("cameras", {}).keys())
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||||
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.put(
|
||||||
|
"/config/set",
|
||||||
|
json={
|
||||||
|
"config_data": {"birdseye": {"inactivity_threshold": 5}},
|
||||||
|
"update_topic": "config/birdseye",
|
||||||
|
"requires_restart": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
self.assertIsNot(holder.config, boot_config)
|
||||||
|
self.assertIs(holder.config, app.frigate_config)
|
||||||
|
self.assertEqual(holder.config.birdseye.inactivity_threshold, 5)
|
||||||
|
finally:
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import unittest
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from frigate.api.config_util import swap_runtime_config
|
from frigate.api.config_util import swap_runtime_config
|
||||||
|
from frigate.config.holder import ConfigHolder
|
||||||
|
|
||||||
|
|
||||||
class TestSwapRuntimeConfig(unittest.TestCase):
|
class TestSwapRuntimeConfig(unittest.TestCase):
|
||||||
@@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
def _make_app(self) -> MagicMock:
|
def _make_app(self) -> MagicMock:
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
app.dispatcher.comms = [MagicMock(), MagicMock()]
|
app.dispatcher.comms = [MagicMock(), MagicMock()]
|
||||||
|
app.config_holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||||
return app
|
return app
|
||||||
|
|
||||||
def test_rebinds_all_references(self) -> None:
|
def test_rebinds_all_references(self) -> None:
|
||||||
@@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
||||||
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_updates_the_config_holder(self) -> None:
|
||||||
|
app = self._make_app()
|
||||||
|
holder = app.config_holder
|
||||||
|
config = MagicMock(name="new_config")
|
||||||
|
|
||||||
|
swap_runtime_config(app, config)
|
||||||
|
|
||||||
|
self.assertIs(holder.config, config)
|
||||||
|
|
||||||
|
def test_deferred_factory_builds_from_the_swapped_config(self) -> None:
|
||||||
|
"""A watchdog-style factory must not rebuild from the boot config.
|
||||||
|
|
||||||
|
The factories in FrigateApp are lambdas evaluated when a process is
|
||||||
|
restarted, long after a user may have saved. Reading through the
|
||||||
|
holder is what keeps a rebuilt process from reverting every change
|
||||||
|
made since Frigate started.
|
||||||
|
"""
|
||||||
|
app = self._make_app()
|
||||||
|
holder = app.config_holder
|
||||||
|
boot_config = holder.config
|
||||||
|
factory = lambda: holder.config # noqa: E731
|
||||||
|
self.assertIs(factory(), boot_config)
|
||||||
|
|
||||||
|
config = MagicMock(name="new_config")
|
||||||
|
swap_runtime_config(app, config)
|
||||||
|
|
||||||
|
self.assertIs(factory(), config)
|
||||||
|
|
||||||
def test_tolerates_missing_optional_collaborators(self) -> None:
|
def test_tolerates_missing_optional_collaborators(self) -> None:
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
app.profile_manager = None
|
app.profile_manager = None
|
||||||
app.stats_emitter = None
|
app.stats_emitter = None
|
||||||
app.dispatcher = None
|
app.dispatcher = None
|
||||||
|
app.config_holder = None
|
||||||
config = MagicMock(name="new_config")
|
config = MagicMock(name="new_config")
|
||||||
|
|
||||||
# must not raise when the optional collaborators are absent
|
# must not raise when the optional collaborators are absent
|
||||||
|
|||||||
@@ -472,6 +472,18 @@ def sanitize_float(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def has_non_finite_number(value: Any) -> bool:
|
||||||
|
"""Return True if any number in a parsed JSON value is NaN or infinite."""
|
||||||
|
if isinstance(value, float):
|
||||||
|
return not math.isfinite(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(has_non_finite_number(v) for v in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(has_non_finite_number(v) for v in value)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
return 1 - cosine_distance(a, b)
|
return 1 - cosine_distance(a, b)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user