From 70d629bf9343ef20ecf836626e61e361f750d018 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 16 Jul 2026 08:00:52 -0600 Subject: [PATCH 1/8] Update OpenVINO model generation (#23733) --- docker/main/Dockerfile | 4 +-- docker/main/build_ov_model.py | 47 +++++++++++++++++++++++++++------ docker/main/requirements-ov.txt | 3 +-- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 3ba1772f41..bf17146e30 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -81,10 +81,10 @@ RUN --mount=type=bind,source=docker/main/install_tempio.sh,target=/deps/install_ FROM base_host AS ov-converter ARG DEBIAN_FRONTEND -# Install OpenVino Runtime and Dev library +# Install OpenVINO for model conversion COPY docker/main/requirements-ov.txt /requirements-ov.txt RUN apt-get -qq update \ - && apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ + && apt-get -qq install -y wget python3 python3-distutils \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && python3 get-pip.py "pip" \ diff --git a/docker/main/build_ov_model.py b/docker/main/build_ov_model.py index 2888d87a85..9d028159c2 100644 --- a/docker/main/build_ov_model.py +++ b/docker/main/build_ov_model.py @@ -1,11 +1,42 @@ -import openvino as ov -from openvino.tools import mo +"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR. -ov_model = mo.convert_model( +Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow +frontend converts the Object Detection API frozen graph natively; the four TF +outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style +tensor that Frigate's OpenVINO detector expects, and the input is flipped to +BGR to match the legacy reverse_input_channels behavior. +""" + +import numpy as np +import openvino as ov +from openvino import opset8 as ops +from openvino.preprocess import PrePostProcessor + +model = ov.convert_model( "/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb", - compress_to_fp16=True, - transformations_config="/usr/local/lib/python3.11/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json", - tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config", - reverse_input_channels=True, + input=[("image_tensor:0", [1, 300, 300, 3])], ) -ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml") + +# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax) +boxes = model.output("detection_boxes:0").get_node().input_value(0) +classes = model.output("detection_classes:0").get_node().input_value(0) +scores = model.output("detection_scores:0").get_node().input_value(0) + +# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax) +boxes = ops.gather(boxes, [1, 0, 3, 2], 2) +classes = ops.unsqueeze(classes, 2) +scores = ops.unsqueeze(scores, 2) +image_id = ops.multiply(scores, np.float32(0.0)) + +detections = ops.concat([image_id, classes, scores, boxes], 2) +detections = ops.unsqueeze(detections, 1) +detections.output(0).get_tensor().set_names({"detection_out"}) + +model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2") + +ppp = PrePostProcessor(model) +ppp.input().tensor().set_layout(ov.Layout("NHWC")) +ppp.input().preprocess().reverse_channels() +model = ppp.build() + +ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True) diff --git a/docker/main/requirements-ov.txt b/docker/main/requirements-ov.txt index 6fd1ca55d9..2df7890dd5 100644 --- a/docker/main/requirements-ov.txt +++ b/docker/main/requirements-ov.txt @@ -1,3 +1,2 @@ numpy -tensorflow -openvino-dev>=2024.0.0 \ No newline at end of file +openvino >= 2026.2.0 From f1028d0c36b782d03512e551564176c3dae2b27a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:05:21 -0500 Subject: [PATCH 2/8] Fix persisted runtime camera toggles (#23734) * preserve runtime camera toggles across config saves Runtime toggles (camera on/off, detect, recordings, snapshots, audio) mutate the in-memory config and persist an override to .runtime_state.json. /api/config/set re-parses yaml into a fresh FrigateConfig and swaps it in, re-applying the yaml and profile layers but dropping the runtime layer, so a camera turned off from the dashboard came back on when an unrelated camera was saved. The workers were never notified, so it only appeared to come back: the UI streamed go2rtc while ffmpeg stayed stopped. Extract the startup replay into Dispatcher.apply_runtime_state() and call it from config_set after the swap, re-layering the overrides and republishing them so workers and the UI reconverge. Remove the broad clear_runtime_state() from ProfileManager.update_config, which is only ever reached from config_set: with a profile active, every save wiped every camera's overrides from disk. The broad wipe stays in activate_profile, where a real profile switch does invalidate the steady state. Saves still clear the keys they rewrote via clear_runtime_state_for_yaml_keys, so yaml wins where the two disagree. * sync runtime config on camera delete and prune its overrides Deleting a camera re-parsed yaml into a fresh FrigateConfig but only rebound app.frigate_config and genai_manager, never dispatcher.config (nor profile_manager, stats_emitter, or the runtime overrides). The API and the dispatcher then drifted onto different config objects until the next config save re-synced them, so the API reported surviving cameras with their yaml enabled state while the dispatcher still acted on their real runtime state. Extract the config swap that config_set already does into a shared swap_runtime_config helper and call it from both sites, so every collaborator is rebound and the surviving cameras' runtime toggles are re-layered. Also drop the deleted camera's persisted overrides via a new clear_camera so a camera later added under the same name does not inherit them. --- frigate/api/app.py | 15 +- frigate/api/camera.py | 12 +- frigate/api/config_util.py | 33 +++++ frigate/comms/dispatcher.py | 48 ++++++- frigate/comms/runtime_state.py | 19 +++ frigate/config/profile_manager.py | 9 +- frigate/test/http_api/test_http_camera.py | 132 ++++++++++++++++++ frigate/test/http_api/test_http_config_set.py | 118 ++++++++++++++++ frigate/test/test_config_util.py | 55 ++++++++ frigate/test/test_dispatcher_runtime_state.py | 40 ++++++ frigate/test/test_profiles.py | 26 +++- frigate/test/test_runtime_state.py | 19 +++ 12 files changed, 496 insertions(+), 30 deletions(-) create mode 100644 frigate/api/config_util.py create mode 100644 frigate/test/http_api/test_http_camera.py create mode 100644 frigate/test/test_config_util.py diff --git a/frigate/api/app.py b/frigate/api/app.py index 49b555606d..7f78f4b56c 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -31,6 +31,7 @@ from frigate.api.auth import ( get_allowed_cameras_for_filter, require_role, ) +from frigate.api.config_util import swap_runtime_config from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import ( AppConfigSetBody, @@ -915,19 +916,7 @@ def config_set(request: Request, body: AppConfigSetBody): if body.requires_restart == 0 or body.update_topic: old_config: FrigateConfig = request.app.frigate_config - request.app.frigate_config = config - request.app.genai_manager.update_config(config) - - if request.app.profile_manager is not None: - request.app.profile_manager.update_config(config) - - if request.app.stats_emitter is not None: - request.app.stats_emitter.config = config - - if request.app.dispatcher is not None: - request.app.dispatcher.config = config - for comm in request.app.dispatcher.comms: - comm.config = config + swap_runtime_config(request.app, config) if body.update_topic: if body.update_topic.startswith("config/cameras/"): diff --git a/frigate/api/camera.py b/frigate/api/camera.py index a86c35883f..29e861b3f5 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -25,6 +25,7 @@ from frigate.api.auth import ( require_go2rtc_stream_access, require_role, ) +from frigate.api.config_util import swap_runtime_config from frigate.api.defs.request.app_body import CameraSetBody from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig @@ -1254,9 +1255,14 @@ async def delete_camera( status_code=500, ) - # Update runtime config - request.app.frigate_config = config - request.app.genai_manager.update_config(config) + # rebind every collaborator to the new config and re-layer runtime + # toggles for the surviving cameras, same as /api/config/set + swap_runtime_config(request.app, config) + + # drop the deleted camera's persisted overrides so a camera later + # added under the same name doesn't inherit them + if request.app.dispatcher is not None: + request.app.dispatcher.clear_runtime_state_for_camera(camera_name) # Publish removal to stop ffmpeg processes and clean up runtime state request.app.config_publisher.publish_update( diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py new file mode 100644 index 0000000000..6a95a4ab01 --- /dev/null +++ b/frigate/api/config_util.py @@ -0,0 +1,33 @@ +"""Shared helpers for applying a freshly parsed config to the running app.""" + +from fastapi import FastAPI + +from frigate.config import FrigateConfig + + +def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: + """Point every long-lived collaborator at a newly parsed config object. + + Both /api/config/set and camera deletion re-parse yaml into a fresh + FrigateConfig and must rebind the same set of references, or the API and + the dispatcher drift onto different objects (the API reports one camera + state while the dispatcher acts on another). Runtime toggle overrides are + re-layered last: the swap rebuilt every camera from yaml, so without this a + camera the user turned off would silently come back on. + """ + app.frigate_config = config + app.genai_manager.update_config(config) + + if app.profile_manager is not None: + app.profile_manager.update_config(config) + + if app.stats_emitter is not None: + app.stats_emitter.config = config + + if app.dispatcher is not None: + app.dispatcher.config = config + + for comm in app.dispatcher.comms: + comm.config = config + + app.dispatcher.apply_runtime_state() diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 6cb4f21b07..29f5fd97bc 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -404,38 +404,64 @@ class Dispatcher: for comm in self.comms: comm.stop() - def restore_runtime_state(self) -> None: + def apply_runtime_state(self) -> dict[str, dict[str, bool]]: """Replay persisted runtime overrides through the camera settings handlers. - Called once after Frigate startup completes so processing threads can - receive the resulting ``config_updater`` broadcasts. Unknown cameras - and topics are skipped; handler exceptions are logged and replay - continues for remaining entries. + Routing through the handlers (rather than mutating config directly) is + deliberate: they publish the ``config_updater`` broadcast and the + retained MQTT state as a side effect, so worker processes and the UI + converge on the replayed value. Unknown cameras and topics are skipped; + handler exceptions are logged and replay continues for the rest. + + Returns: + The entries handed to a handler without raising, keyed by camera + then topic. A handler can still refuse the value internally (an ON + payload for a camera that is not enabled_in_config, for example), + so this is not proof the override took effect. """ state = self._runtime_state.load() + applied: dict[str, dict[str, bool]] = {} + for camera_name, features in state.items(): if camera_name not in self.config.cameras: continue + for topic, value in features.items(): handler = self._camera_settings_handlers.get(topic) + if handler is None: continue + payload = "ON" if value else "OFF" + try: handler(camera_name, payload) except Exception: logger.exception( - "Failed to restore runtime state %s.%s=%s", + "Failed to apply runtime state %s.%s=%s", camera_name, topic, payload, ) continue + + applied.setdefault(camera_name, {})[topic] = value + + return applied + + def restore_runtime_state(self) -> None: + """Replay persisted runtime overrides once Frigate startup completes. + + Called after every ``config_updater`` subscriber is up so the resulting + broadcasts are not dropped by ZMQ PUB/SUB. + """ + for camera_name, features in self.apply_runtime_state().items(): + for topic, value in features.items(): logger.info( "Restored runtime state: %s.%s=%s", camera_name, topic, - payload, + "ON" if value else "OFF", ) def clear_runtime_state_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: @@ -458,6 +484,14 @@ class Dispatcher: """ self._runtime_state.clear_all() + def clear_runtime_state_for_camera(self, camera: str) -> None: + """Drop all persisted runtime overrides for a deleted camera. + + Called by camera deletion so a camera later added under the same name + does not inherit the removed camera's stale toggles. + """ + self._runtime_state.clear_camera(camera) + def _on_detect_command(self, camera_name: str, payload: str) -> None: """Callback for detect topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/comms/runtime_state.py b/frigate/comms/runtime_state.py index 222d488ec9..be1b850e92 100644 --- a/frigate/comms/runtime_state.py +++ b/frigate/comms/runtime_state.py @@ -96,6 +96,25 @@ class RuntimeStatePersistence: except OSError: logger.exception("Failed to clear runtime state") + def clear_camera(self, camera: str) -> None: + """Drop every stored override for a single camera. + + Called when a camera is deleted so a camera later added under the same + name does not inherit the removed camera's stale toggles. + """ + try: + with FileLock(self._lock_path, timeout=self._lock_timeout): + data = self._read_locked() + cameras = data.get("cameras") + if not isinstance(cameras, dict) or camera not in cameras: + return + del cameras[camera] + self._write_locked(data) + except Timeout: + logger.error("Timed out clearing runtime state for camera") + except OSError: + logger.exception("Failed to clear runtime state for camera") + def clear_for_yaml_keys(self, dotted_keys: Iterable[str]) -> None: """Remove stored entries whose YAML key was just rewritten. diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index cfd0dc9df5..2aafbab57d 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -141,6 +141,11 @@ class ProfileManager: Preserves active profile state: re-snapshots base configs from the new (freshly parsed) config, then re-applies profile overrides if a profile was active. + + Deliberately does not clear the dispatcher's runtime overrides. This is + the config-save path, not a profile switch: the save only invalidates + the toggles it rewrote in yaml, which /api/config/set already clears by + key. The broad wipe belongs to activate_profile alone. """ current_active = self.config.active_profile self.config = new_config @@ -164,10 +169,6 @@ class ProfileManager: self.config.active_profile = None self._persist_active_profile(None) - # drop all runtime overrides so they don't replay stale values on restart - if self.dispatcher is not None: - self.dispatcher.clear_runtime_state() - def activate_profile( self, profile_name: str | None, diff --git a/frigate/test/http_api/test_http_camera.py b/frigate/test/http_api/test_http_camera.py new file mode 100644 index 0000000000..afeac1bde2 --- /dev/null +++ b/frigate/test/http_api/test_http_camera.py @@ -0,0 +1,132 @@ +"""Tests for the camera delete endpoint's runtime config handling.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + +import ruamel.yaml + +from frigate.config import FrigateConfig +from frigate.config.camera.updater import CameraConfigUpdatePublisher +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestDeleteCameraRuntimeConfig(BaseTestHttp): + """Deleting a camera must keep the API and dispatcher on the same config.""" + + def setUp(self): + super().setUp(models=[Event, Recordings, ReviewSegment]) + self.minimal_config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_yard": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 720, "width": 1280, "fps": 10}, + }, + }, + } + + def _write_config_file(self): + yaml = ruamel.yaml.YAML() + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) + yaml.dump(self.minimal_config, f) + f.close() + return f.name + + def _create_app_with_dispatcher(self, dispatcher): + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + dispatcher=dispatcher, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + return { + "username": request.headers.get("remote-user"), + "role": request.headers.get("remote-role"), + } + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app, mock_publisher + + @patch("frigate.api.camera.requests.delete") + @patch("frigate.api.camera.cleanup_camera_files") + @patch("frigate.api.camera.cleanup_camera_db") + @patch("frigate.api.camera.find_config_file") + def test_delete_syncs_dispatcher_and_prunes_runtime_state( + self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete + ): + """Deleting a camera swaps every config reference and prunes its state.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + mock_cleanup_db.return_value = ({}, []) + + dispatcher = MagicMock() + dispatcher.comms = [] + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.delete("/cameras/front_door") + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.json()["success"]) + + # the dispatcher must be moved onto the same new object the API + # now serves, and that object must no longer contain the camera + self.assertIs(dispatcher.config, app.frigate_config) + self.assertNotIn("front_door", dispatcher.config.cameras) + self.assertIn("back_yard", dispatcher.config.cameras) + + # surviving cameras' overrides are re-layered onto the new object + dispatcher.apply_runtime_state.assert_called_once_with() + + # the deleted camera's persisted overrides are pruned + dispatcher.clear_runtime_state_for_camera.assert_called_once_with( + "front_door" + ) + finally: + os.unlink(config_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 48b1ac2c76..cd249c6fa6 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -91,6 +91,124 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): return app, mock_publisher + def _create_app_with_dispatcher(self, dispatcher): + """Create app with a mocked config publisher and a real-ish dispatcher.""" + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user + from frigate.api.fastapi_app import create_fastapi_app + + mock_publisher = Mock(spec=CameraConfigUpdatePublisher) + mock_publisher.publisher = MagicMock() + + app = create_fastapi_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + None, + mock_publisher, + None, + dispatcher=dispatcher, + enforce_default_admin=False, + ) + + async def mock_get_current_user(request: Request): + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") + return {"username": username, "role": role} + + async def mock_get_allowed_cameras_for_filter(request: Request): + return list(self.minimal_config.get("cameras", {}).keys()) + + app.dependency_overrides[get_current_user] = mock_get_current_user + app.dependency_overrides[get_allowed_cameras_for_filter] = ( + mock_get_allowed_cameras_for_filter + ) + + return app, mock_publisher + + @patch("frigate.api.app.find_config_file") + def test_runtime_disabled_camera_survives_unrelated_save(self, mock_find_config): + """A camera turned off at runtime stays off when another camera is saved.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + dispatcher = MagicMock() + dispatcher.comms = [] + + # front_door was turned off via the UI: the override is on disk, and + # yaml still says enabled: true. Stand in for the real replay, which + # reads dispatcher.config - the object the endpoint just swapped in. + def fake_apply(): + dispatcher.config.cameras["front_door"].enabled = False + return {"front_door": {"enabled": False}} + + dispatcher.apply_runtime_state.side_effect = fake_apply + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": { + "cameras": {"back_yard": {"detect": {"fps": 7}}} + }, + "requires_restart": 0, + }, + ) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.json()["success"]) + + # the swap must be repaired: the new config object the API and + # dispatcher now share has to still show front_door as off + dispatcher.apply_runtime_state.assert_called_once_with() + self.assertFalse(app.frigate_config.cameras["front_door"].enabled) + self.assertIs(dispatcher.config, app.frigate_config) + + # yaml-wins ordering: the surgical clear for rewritten keys + # must run before the replay, or a save that rewrote a toggle + # would have its old override resurrected + call_names = [name for name, _, _ in dispatcher.mock_calls] + self.assertLess( + call_names.index("clear_runtime_state_for_yaml_keys"), + call_names.index("apply_runtime_state"), + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_no_reapply_when_config_is_not_swapped(self, mock_find_config): + """A restart-required save with no update topic never swaps, so no replay.""" + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + dispatcher = MagicMock() + dispatcher.comms = [] + + try: + app, _ = self._create_app_with_dispatcher(dispatcher) + + with AuthTestClient(app) as client: + resp = client.put( + "/config/set", + json={ + "config_data": {"mqtt": {"host": "other"}}, + "requires_restart": 1, + }, + ) + + self.assertEqual(resp.status_code, 200) + dispatcher.apply_runtime_state.assert_not_called() + finally: + os.unlink(config_path) + def _write_config_file(self): """Write the minimal config to a temp YAML file and return the path.""" yaml = ruamel.yaml.YAML() diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py new file mode 100644 index 0000000000..310b233aaa --- /dev/null +++ b/frigate/test/test_config_util.py @@ -0,0 +1,55 @@ +"""Tests for the shared runtime config swap helper.""" + +import unittest +from unittest.mock import MagicMock + +from frigate.api.config_util import swap_runtime_config + + +class TestSwapRuntimeConfig(unittest.TestCase): + """swap_runtime_config rebinds every collaborator to the new config.""" + + def _make_app(self) -> MagicMock: + app = MagicMock() + app.dispatcher.comms = [MagicMock(), MagicMock()] + return app + + def test_rebinds_all_references(self) -> None: + app = self._make_app() + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + self.assertIs(app.frigate_config, config) + app.genai_manager.update_config.assert_called_once_with(config) + app.profile_manager.update_config.assert_called_once_with(config) + self.assertIs(app.stats_emitter.config, config) + self.assertIs(app.dispatcher.config, config) + for comm in app.dispatcher.comms: + self.assertIs(comm.config, config) + + def test_reapplies_runtime_state_after_swap(self) -> None: + app = self._make_app() + config = MagicMock(name="new_config") + + swap_runtime_config(app, config) + + # the swap rebuilds cameras from yaml, so overrides must be re-layered + app.dispatcher.apply_runtime_state.assert_called_once_with() + + def test_tolerates_missing_optional_collaborators(self) -> None: + app = MagicMock() + app.profile_manager = None + app.stats_emitter = None + app.dispatcher = None + config = MagicMock(name="new_config") + + # must not raise when the optional collaborators are absent + swap_runtime_config(app, config) + + self.assertIs(app.frigate_config, config) + app.genai_manager.update_config.assert_called_once_with(config) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_dispatcher_runtime_state.py b/frigate/test/test_dispatcher_runtime_state.py index dae0518d80..e0a91536cb 100644 --- a/frigate/test/test_dispatcher_runtime_state.py +++ b/frigate/test/test_dispatcher_runtime_state.py @@ -126,6 +126,40 @@ class TestRestoreRuntimeState(unittest.TestCase): self.dispatcher.restore_runtime_state() self.handler_mocks["detect"].assert_called_once_with("front_door", "ON") + def test_apply_runtime_state_replays_through_handlers(self) -> None: + """The extracted method replays every stored entry.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"front_door": {"enabled": False, "detect": True}}, + ): + self.dispatcher.apply_runtime_state() + + self.handler_mocks["enabled"].assert_called_once_with("front_door", "OFF") + self.handler_mocks["detect"].assert_called_once_with("front_door", "ON") + + def test_apply_runtime_state_returns_applied_entries(self) -> None: + """Callers get back what was replayed, for logging and assertions.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"front_door": {"enabled": False}, "nope": {"enabled": True}}, + ): + applied = self.dispatcher.apply_runtime_state() + + self.assertEqual(applied, {"front_door": {"enabled": False}}) + + def test_restore_runtime_state_still_replays(self) -> None: + """The startup entry point keeps working after the extraction.""" + with patch.object( + self.dispatcher._runtime_state, + "load", + return_value={"back_yard": {"snapshots": False}}, + ): + self.dispatcher.restore_runtime_state() + + self.handler_mocks["snapshots"].assert_called_once_with("back_yard", "OFF") + class TestHandlersPersistViaSet(unittest.TestCase): """Verify each in-scope handler writes to the runtime state on success.""" @@ -212,6 +246,12 @@ class TestClearPassthrough(unittest.TestCase): dispatcher.clear_runtime_state() dispatcher._runtime_state.clear_all.assert_called_once_with() + def test_clear_runtime_state_for_camera_passthrough(self) -> None: + dispatcher = _build_dispatcher({}) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher.clear_runtime_state_for_camera("front_door") + dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door") + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 59dc077466..e2f9a0bed9 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -786,8 +786,15 @@ class TestProfileManager(unittest.TestCase): dispatcher.clear_runtime_state.assert_not_called() @patch.object(ProfileManager, "_persist_active_profile") - def test_update_config_clears_when_active_profile_reapplies(self, mock_persist): - """After /api/config/set, an active-profile re-application drops state.""" + def test_update_config_preserves_runtime_state_with_active_profile( + self, mock_persist + ): + """A config/set save must not wipe overrides it never rewrote. + + The save path clears matching entries itself via + clear_runtime_state_for_yaml_keys; a broad wipe here would drop + overrides for unrelated cameras. + """ dispatcher = MagicMock() manager = ProfileManager(self.config, self.mock_updater, dispatcher) manager.activate_profile("armed") @@ -795,7 +802,20 @@ class TestProfileManager(unittest.TestCase): new_config = FrigateConfig(**self.config_data) manager.update_config(new_config) - dispatcher.clear_runtime_state.assert_called_once_with() + dispatcher.clear_runtime_state.assert_not_called() + + @patch.object(ProfileManager, "_persist_active_profile") + def test_update_config_still_reapplies_active_profile(self, mock_persist): + """Dropping the wipe must not disturb profile re-application.""" + dispatcher = MagicMock() + manager = ProfileManager(self.config, self.mock_updater, dispatcher) + manager.activate_profile("armed") + + new_config = FrigateConfig(**self.config_data) + manager.update_config(new_config) + + self.assertEqual(manager.config, new_config) + self.assertEqual(new_config.active_profile, "armed") @patch.object(ProfileManager, "_persist_active_profile") def test_update_config_does_not_clear_when_no_active_profile(self, mock_persist): diff --git a/frigate/test/test_runtime_state.py b/frigate/test/test_runtime_state.py index 6143184030..5a373ade9e 100644 --- a/frigate/test/test_runtime_state.py +++ b/frigate/test/test_runtime_state.py @@ -131,6 +131,25 @@ class TestRuntimeStatePersistence(unittest.TestCase): self.store.clear_all() self.assertEqual(self.store.load(), {}) + def test_clear_camera_removes_only_that_camera(self) -> None: + self.store.set("front_door", "enabled", False) + self.store.set("front_door", "detect", False) + self.store.set("back_yard", "audio", False) + + self.store.clear_camera("front_door") + + self.assertEqual(self.store.load(), {"back_yard": {"audio": False}}) + + def test_clear_camera_is_noop_for_unknown_camera(self) -> None: + self.store.set("front_door", "enabled", False) + self.store.clear_camera("side_gate") + self.assertEqual(self.store.load(), {"front_door": {"enabled": False}}) + + def test_clear_camera_is_safe_when_file_missing(self) -> None: + # No prior set() calls, so the file does not exist + self.store.clear_camera("front_door") + self.assertEqual(self.store.load(), {}) + if __name__ == "__main__": unittest.main() From 48aaafba3c84ec1b23f175df9e93befb5f1a9e2e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:41 -0500 Subject: [PATCH 3/8] re-apply runtime overrides to the config without re-broadcasting them (#23739) /api/config/set and camera deletion re-parse yaml into a fresh FrigateConfig and swap it in, then re-layered the persisted runtime toggle overrides so a camera the user turned off wouldn't come back on. That re-layer ran apply_runtime_state, which replays each override through the command handlers, so every save re-published a ZMQ config update, a retained MQTT state message, and a runtime-state disk write for every camera with a stored toggle. All of it was redundant: the worker processes were never swapped and still hold the live toggle values, so only the in-process config object the API and dispatcher read was out of date. The extra traffic churned the retained MQTT topics, amplified disk writes, and co-drained enabled updates with other topics on the config socket. Add Dispatcher.reapply_runtime_state_to_config, which corrects only the swapped-in config object, mirroring the field mutations and gates of the _on_*_command handlers with no ZMQ, MQTT, or disk writes. swap_runtime_config now calls it instead of apply_runtime_state; apply_runtime_state is unchanged and still used at startup, where the workers genuinely must be told. --- frigate/api/config_util.py | 4 +- frigate/comms/dispatcher.py | 42 +++++++ frigate/test/http_api/test_http_camera.py | 2 +- frigate/test/http_api/test_http_config_set.py | 11 +- frigate/test/test_config_util.py | 2 +- frigate/test/test_dispatcher_runtime_state.py | 110 ++++++++++++++++++ 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py index 6a95a4ab01..5d963ad725 100644 --- a/frigate/api/config_util.py +++ b/frigate/api/config_util.py @@ -30,4 +30,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: for comm in app.dispatcher.comms: comm.config = config - app.dispatcher.apply_runtime_state() + # workers still hold the live toggle values, so correct only the + # config object here rather than re-broadcasting every override + app.dispatcher.reapply_runtime_state_to_config() diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 29f5fd97bc..ec16f9744e 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -492,6 +492,48 @@ class Dispatcher: """ self._runtime_state.clear_camera(camera) + def reapply_runtime_state_to_config(self) -> None: + """Re-apply persisted runtime overrides to the swapped-in config object. + + After config/set (or a camera delete) parses fresh yaml and swaps the + config, the worker processes still hold the live toggle values and the + overrides are already on disk, so only the in-process config object is + out of date. Unlike apply_runtime_state (used at startup, where workers + must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects + the config the API and dispatcher read. + + The field mutations and gates mirror the _on_*_command handlers; keep + the two in sync if a tracked toggle is added or its gate changes. + """ + state = self._runtime_state.load() + + for camera_name, features in state.items(): + camera = self.config.cameras.get(camera_name) + + if camera is None: + continue + + for topic, value in features.items(): + if topic == "enabled": + if value and not camera.enabled_in_config: + continue + camera.enabled = value + elif topic == "detect": + camera.detect.enabled = value + # detection requires motion, mirror the handler coupling + if value and not camera.motion.enabled: + camera.motion.enabled = True + elif topic == "snapshots": + camera.snapshots.enabled = value + elif topic == "recordings": + if value and not camera.record.enabled_in_config: + continue + camera.record.enabled = value + elif topic == "audio": + if value and not camera.audio.enabled_in_config: + continue + camera.audio.enabled = value + def _on_detect_command(self, camera_name: str, payload: str) -> None: """Callback for detect topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/test/http_api/test_http_camera.py b/frigate/test/http_api/test_http_camera.py index afeac1bde2..cab2d16675 100644 --- a/frigate/test/http_api/test_http_camera.py +++ b/frigate/test/http_api/test_http_camera.py @@ -118,7 +118,7 @@ class TestDeleteCameraRuntimeConfig(BaseTestHttp): self.assertIn("back_yard", dispatcher.config.cameras) # surviving cameras' overrides are re-layered onto the new object - dispatcher.apply_runtime_state.assert_called_once_with() + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() # the deleted camera's persisted overrides are pruned dispatcher.clear_runtime_state_for_camera.assert_called_once_with( diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index cd249c6fa6..0540a50818 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -143,11 +143,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): # front_door was turned off via the UI: the override is on disk, and # yaml still says enabled: true. Stand in for the real replay, which # reads dispatcher.config - the object the endpoint just swapped in. - def fake_apply(): + def fake_reapply(): dispatcher.config.cameras["front_door"].enabled = False - return {"front_door": {"enabled": False}} - dispatcher.apply_runtime_state.side_effect = fake_apply + dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply try: app, _ = self._create_app_with_dispatcher(dispatcher) @@ -168,7 +167,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): # the swap must be repaired: the new config object the API and # dispatcher now share has to still show front_door as off - dispatcher.apply_runtime_state.assert_called_once_with() + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() self.assertFalse(app.frigate_config.cameras["front_door"].enabled) self.assertIs(dispatcher.config, app.frigate_config) @@ -178,7 +177,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): call_names = [name for name, _, _ in dispatcher.mock_calls] self.assertLess( call_names.index("clear_runtime_state_for_yaml_keys"), - call_names.index("apply_runtime_state"), + call_names.index("reapply_runtime_state_to_config"), ) finally: os.unlink(config_path) @@ -205,7 +204,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): ) self.assertEqual(resp.status_code, 200) - dispatcher.apply_runtime_state.assert_not_called() + dispatcher.reapply_runtime_state_to_config.assert_not_called() finally: os.unlink(config_path) diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py index 310b233aaa..300b0b0c51 100644 --- a/frigate/test/test_config_util.py +++ b/frigate/test/test_config_util.py @@ -35,7 +35,7 @@ class TestSwapRuntimeConfig(unittest.TestCase): swap_runtime_config(app, config) # the swap rebuilds cameras from yaml, so overrides must be re-layered - app.dispatcher.apply_runtime_state.assert_called_once_with() + app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with() def test_tolerates_missing_optional_collaborators(self) -> None: app = MagicMock() diff --git a/frigate/test/test_dispatcher_runtime_state.py b/frigate/test/test_dispatcher_runtime_state.py index e0a91536cb..dc6bb0bf2d 100644 --- a/frigate/test/test_dispatcher_runtime_state.py +++ b/frigate/test/test_dispatcher_runtime_state.py @@ -253,5 +253,115 @@ class TestClearPassthrough(unittest.TestCase): dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door") +class TestReapplyRuntimeStateToConfig(unittest.TestCase): + """The silent re-apply corrects the config object with no side effects.""" + + def _dispatcher_with( + self, cameras: dict[str, MagicMock], state: dict + ) -> Dispatcher: + dispatcher = _build_dispatcher(cameras) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher._runtime_state.load.return_value = state + dispatcher.publish = MagicMock() + return dispatcher + + def test_mutates_every_tracked_field(self) -> None: + cameras = {"front_door": _make_camera_mock()} + dispatcher = self._dispatcher_with( + cameras, + { + "front_door": { + "enabled": False, + "detect": False, + "snapshots": False, + "recordings": False, + "audio": False, + } + }, + ) + + dispatcher.reapply_runtime_state_to_config() + + cam = cameras["front_door"] + self.assertFalse(cam.enabled) + self.assertFalse(cam.detect.enabled) + self.assertFalse(cam.snapshots.enabled) + self.assertFalse(cam.record.enabled) + self.assertFalse(cam.audio.enabled) + + def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, + {"front_door": {"enabled": False}}, + ) + + dispatcher.reapply_runtime_state_to_config() + + dispatcher.config_updater.publish_update.assert_not_called() + dispatcher._runtime_state.set.assert_not_called() + dispatcher.publish.assert_not_called() + + def test_respects_enabled_in_config_gate(self) -> None: + # an ON override for a camera disabled in yaml must not enable it + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=False) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].enabled) + + def test_respects_recordings_and_audio_gates(self) -> None: + # ON overrides for recordings/audio not enabled in yaml must be ignored + cameras = { + "front_door": _make_camera_mock( + record_enabled=False, + record_enabled_in_config=False, + audio_enabled=False, + audio_enabled_in_config=False, + ) + } + dispatcher = self._dispatcher_with( + cameras, {"front_door": {"recordings": True, "audio": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].record.enabled) + self.assertFalse(cameras["front_door"].audio.enabled) + + def test_applies_on_override_when_gate_passes(self) -> None: + # a camera off in yaml but enabled_in_config keeps its runtime-on state + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=True) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cameras["front_door"].enabled) + + def test_detect_on_couples_motion(self) -> None: + cam = _make_camera_mock(detect_enabled=False) + cam.motion.enabled = False + dispatcher = self._dispatcher_with( + {"front_door": cam}, {"front_door": {"detect": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cam.detect.enabled) + self.assertTrue(cam.motion.enabled) + + def test_skips_camera_not_in_config(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}} + ) + + # a stale entry for a deleted camera must be ignored, not raise + dispatcher.reapply_runtime_state_to_config() + + if __name__ == "__main__": unittest.main() From dd7e9f1bc58d51bac52cdb4d84748fa035add75b Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 16 Jul 2026 22:58:26 +0200 Subject: [PATCH 4/8] Fix invalid escape sequences in RTSP password test (#23740) --- frigate/test/test_camera_pw.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frigate/test/test_camera_pw.py b/frigate/test/test_camera_pw.py index 0964f38bea..af4cecbd22 100644 --- a/frigate/test/test_camera_pw.py +++ b/frigate/test/test_camera_pw.py @@ -8,9 +8,7 @@ from frigate.util.builtin import clean_camera_user_pass, escape_special_characte class TestUserPassCleanup(unittest.TestCase): def setUp(self) -> None: self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live" - self.rtsp_with_special_pass = ( - "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\{\}\[\]@@192.168.0.2:554/live" - ) + self.rtsp_with_special_pass = "rtsp://user:password`~!@#$%^&*()-_;',.<>:\"\\{\\}\\[\\]@@192.168.0.2:554/live" self.rtsp_no_pass = "rtsp://192.168.0.3:554/live" def test_cleanup(self): From c17538aff9ea3d77fd5deb6d956a17b143502fa7 Mon Sep 17 00:00:00 2001 From: GuoQing Liu <842607283@qq.com> Date: Fri, 17 Jul 2026 20:30:02 +0800 Subject: [PATCH 5/8] Frontend Miscellaneous fixes (#23751) * fix: fix logger page i18n * fix: fix button components text * fix: fix command components scrollbar * revert: revert fix button components text --- web/src/components/filter/LogSettingsButton.tsx | 2 +- web/src/components/indicators/Chip.tsx | 4 +++- web/src/components/ui/command.tsx | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/components/filter/LogSettingsButton.tsx b/web/src/components/filter/LogSettingsButton.tsx index 57ba76531e..83f2e85b47 100644 --- a/web/src/components/filter/LogSettingsButton.tsx +++ b/web/src/components/filter/LogSettingsButton.tsx @@ -129,7 +129,7 @@ export function GeneralFilterContent({ className="mx-2 w-full cursor-pointer text-primary smart-capitalize" htmlFor={item} > - {item.replaceAll("_", " ")} + {t(`logger.logLevel.${item}`, { ns: "views/settings" })} void; }; export function LogChip({ severity, onClickSeverity }: LogChipProps) { + const { t } = useTranslation(["views/settings"]); const severityClassName = useMemo(() => { switch (severity) { case "info": @@ -73,7 +75,7 @@ export function LogChip({ severity, onClickSeverity }: LogChipProps) { } }} > - {severity} + {t(`logger.logLevel.${severity}`, { ns: "views/settings" })} ); diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx index 64be5e01a1..82183ef908 100644 --- a/web/src/components/ui/command.tsx +++ b/web/src/components/ui/command.tsx @@ -60,7 +60,10 @@ const CommandList = React.forwardRef< >(({ className, ...props }, ref) => ( )); From d02a1156b7ee4ec7c0e7244baa333425790e601e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:00:15 -0500 Subject: [PATCH 6/8] Miscellaneous fixes (0.18 beta) (#23736) * Catch faces that become empty after cropping * don't drop batched camera add/remove config updates TrackedObjectProcessor drained all pending camera config updates at once but handled them in a mutually exclusive if/elif on enabled/add/remove, so only one topic was processed per drain. When an add arrived in the same batch as an enabled update, the add was skipped and the new camera never got a camera state. Adding a camera reliably produced that batch: config_set now re-applies runtime overrides, which republishes an enabled update for every previously toggled camera immediately before the add, in the same request. The dashboard and camera capture still saw the camera (the maintainer does not subscribe to enabled, so it got a clean add-only batch), but object_processing did not, and disabling the camera then crashed with a KeyError on the unguarded camera_states lookup. Handle add and remove independently instead of as exclusive branches so a batched add is no longer dropped, and guard the remove lookup so a missing state is skipped rather than raising. Drop the enabled branch entirely: it only ever set prev_enabled when it was None, but prev_enabled is seeded to a bool at camera state creation and is never None (mypy flags the body as unreachable), and the actual enable/disable transition is already driven by the disabled-state loop from config.enabled. * Don't stay on motion search page when user cancels flow * fix notification test button being blocked by websocket auth * fix overflowing model names in settings genai widget * add note about auth debugging --------- Co-authored-by: Nicolas Mowen --- docs/docs/configuration/authentication.md | 13 ++++++++ frigate/comms/ws.py | 2 -- frigate/data_processing/real_time/face.py | 4 +++ frigate/test/test_ws_auth.py | 14 ++++++++ frigate/track/object_processing.py | 29 ++++++++--------- .../theme/widgets/GenAIModelWidget.tsx | 32 +++++++++++-------- .../views/motion-search/MotionSearchView.tsx | 32 +++++++++++++++++-- 7 files changed, 93 insertions(+), 33 deletions(-) diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index a565398774..94d46dba07 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -262,6 +262,19 @@ In this example: - Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). +:::note + +If a user isn't getting the role you expect, enable debug logging to see exactly what headers Frigate is receiving from your proxy: + +```yaml +logger: + default: info + logs: + frigate.api.auth: debug +``` + +::: + #### Port Considerations **Authenticated Port (8971)** diff --git a/frigate/comms/ws.py b/frigate/comms/ws.py index ac047b053f..ccb5d42890 100644 --- a/frigate/comms/ws.py +++ b/frigate/comms/ws.py @@ -23,7 +23,6 @@ from frigate.const import ( EXPIRE_AUDIO_ACTIVITY, INSERT_MANY_RECORDINGS, INSERT_PREVIEW, - NOTIFICATION_TEST, REQUEST_REGION_GRID, UPDATE_AUDIO_ACTIVITY, UPDATE_AUDIO_TRANSCRIPTION_STATE, @@ -57,7 +56,6 @@ _WS_BLOCKED_TOPICS = frozenset( UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_BIRDSEYE_LAYOUT, UPDATE_AUDIO_TRANSCRIPTION_STATE, - NOTIFICATION_TEST, } ) diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index 83c8a2e55a..ec1fccb45e 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -288,6 +288,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): max(0, face_box[0]) : min(frame.shape[1], face_box[2]), ] + if face_frame.size == 0: + logger.debug(f"Empty face crop for {id}") + return + res = self.recognizer.classify(face_frame) if not res: diff --git a/frigate/test/test_ws_auth.py b/frigate/test/test_ws_auth.py index a9fc6e1320..4fc6701136 100644 --- a/frigate/test/test_ws_auth.py +++ b/frigate/test/test_ws_auth.py @@ -115,6 +115,13 @@ class TestCheckWsAuthorization(unittest.TestCase): ) ) + def test_viewer_blocked_from_notification_test(self): + self.assertFalse( + _check_ws_authorization( + "notification_test", "viewer", self.DEFAULT_SEPARATOR + ) + ) + # --- Admin access --- def test_admin_can_send_restart(self): @@ -134,6 +141,13 @@ class TestCheckWsAuthorization(unittest.TestCase): _check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR) ) + def test_admin_can_send_notification_test(self): + self.assertTrue( + _check_ws_authorization( + "notification_test", "admin", self.DEFAULT_SEPARATOR + ) + ) + # --- Comma-separated roles --- def test_comma_separated_admin_viewer_grants_admin(self): diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 5832d8cdb8..999ec5c04b 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -684,22 +684,21 @@ class TrackedObjectProcessor(threading.Thread): # check for config updates updated_topics = self.camera_config_subscriber.check_for_updates() - if "enabled" in updated_topics: - for camera in updated_topics["enabled"]: - if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = self.config.cameras[ - camera - ].enabled - elif "add" in updated_topics: - for camera in updated_topics["add"]: - self.config.cameras[camera] = ( - self.camera_config_subscriber.camera_configs[camera] - ) - self.create_camera_state(camera) - elif "remove" in updated_topics: + # a single drain can carry several topics at once, so add and + # remove are handled independently rather than as exclusive branches + for camera in updated_topics.get("add", []): + self.config.cameras[camera] = ( + self.camera_config_subscriber.camera_configs[camera] + ) + self.create_camera_state(camera) + + if "remove" in updated_topics: for camera in updated_topics["remove"]: - removed_camera_state = self.camera_states[camera] - removed_camera_state.shutdown() + camera_state = self.camera_states.get(camera) + if camera_state is None: + continue + + camera_state.shutdown() self.camera_states.pop(camera) self.camera_activity.pop(camera, None) self.last_motion_detected.pop(camera, None) diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx index ca5b30d29f..58a30434b4 100644 --- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx +++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx @@ -227,16 +227,18 @@ export function GenAIModelWidget(props: WidgetProps) { aria-expanded={open} disabled={disabled || readonly} className={cn( - "justify-between font-normal", + "min-w-0 justify-between font-normal", !currentLabel && "text-muted-foreground", fieldClassName, )} > - {currentLabel ?? - t("configForm.genaiModel.placeholder", { - ns: "views/settings", - defaultValue: "Select or enter a model…", - })} + + {currentLabel ?? + t("configForm.genaiModel.placeholder", { + ns: "views/settings", + defaultValue: "Select or enter a model…", + })} + @@ -263,12 +265,14 @@ export function GenAIModelWidget(props: WidgetProps) { value={trimmedSearch} onSelect={() => commit(trimmedSearch)} > - - {t("configForm.genaiModel.useCustom", { - ns: "views/settings", - value: trimmedSearch, - defaultValue: 'Use "{{value}}"', - })} + + + {t("configForm.genaiModel.useCustom", { + ns: "views/settings", + value: trimmedSearch, + defaultValue: 'Use "{{value}}"', + })} + )} @@ -287,11 +291,11 @@ export function GenAIModelWidget(props: WidgetProps) { > - {model} + {model} ))} diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index e5cb2f4784..d2b06b6e70 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -770,6 +770,34 @@ export default function MotionSearchView({ }; }, [cancelMotionSearchJobViaBeacon]); + const handleBack = useCallback(() => { + if (onBack) { + onBack(); + } else { + navigate(-1); + } + }, [navigate, onBack]); + + // Dismissing the entry dialog (escape / click outside) before a search has + // run leaves nothing behind it, so cancel the flow instead of revealing an + // empty page. + const handleSearchDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if ( + !nextOpen && + !isSearching && + !hasSearched && + searchResults.length === 0 + ) { + handleBack(); + return; + } + + setIsSearchDialogOpen(nextOpen); + }, + [handleBack, hasSearched, isSearching, searchResults.length], + ); + const handleNewSearch = useCallback(() => { if (jobId && jobCamera) { void cancelMotionSearchJob(jobId, jobCamera); @@ -1238,7 +1266,7 @@ export default function MotionSearchView({ (onBack ? onBack() : navigate(-1))} + onClick={handleBack} > {isDesktop && ( From 6f80bcd19fb40afd280f1d5462d4f776304482e6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:19:37 -0500 Subject: [PATCH 7/8] Miscellaneous fixes (0.18 beta) (#23755) * resolve saved credential sentinel to the stored api_key in the GenAI probe * add profile faq * center the multi-camera export time range on the current playback position * add faq about preview restart cache * clarify exports bulk download --- docs/docs/configuration/profiles.md | 15 ++++ docs/docs/troubleshooting/recordings.md | 16 +++++ docs/docs/usage/exports.md | 2 +- docs/static/frigate-api.yaml | 5 ++ frigate/api/app.py | 11 ++- frigate/api/defs/request/app_body.py | 1 + frigate/test/http_api/test_http_app.py | 71 +++++++++++++++++++ .../theme/widgets/GenAIModelWidget.tsx | 1 + web/src/components/overlay/ExportDialog.tsx | 20 ++++-- 9 files changed, 135 insertions(+), 7 deletions(-) diff --git a/docs/docs/configuration/profiles.md b/docs/docs/configuration/profiles.md index 3eab2bd131..13e08f76d0 100644 --- a/docs/docs/configuration/profiles.md +++ b/docs/docs/configuration/profiles.md @@ -232,6 +232,21 @@ No. Only one profile can be active at a time. Activating a new profile automatic When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well. +### How do I make a YAML profile track no objects at all? + +Set the tracked object list explicitly to an empty list in the profile: + +```yaml +cameras: + front_door: + profiles: + home: + objects: + track: [] +``` + +Leaving the `objects` section empty (or omitting `track`) does not clear the list. Empty sections set no fields, so the profile inherits the full tracked object list from the base config, including anything set at the global level. The same applies to other lists, such as `audio.listen`. + ### Why are some settings missing when I configure a profile override? Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration. diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index c4e8e72b25..372c7b8dcf 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -428,3 +428,19 @@ You'll want to: - [Tune your motion detection settings](/configuration/motion_detection) either by editing your config file or by using the UI's Motion Tuner. + + + +The scrubbing previews (the timelapse clips shown when dragging the History timeline, the secondary-camera previews, and the preview that plays when hovering a review card) are not recorded continuously. Frigate caches low-resolution preview frames in `/tmp/cache` throughout each hour and only assembles them into a finished preview clip **at the top of the hour**. + +In the recommended configuration, `/tmp/cache` is a small in-memory (`tmpfs`) area. When Frigate starts, it tries to restore the current hour's cached frames, so a **soft restart from the UI** preserves them. But if you recreate the Docker container or stop Frigate forcibly by any other means partway through an hour, the in-memory cache is discarded, so no preview clip is produced for that partial hour. + +This is expected behavior, not a bug: + +- Previews for hours that already completed and were written to disk are unaffected. +- The next full hour after a restart will generate previews normally. +- This is unrelated to `shm_size`; increasing shared memory does not change it. + +To avoid the gap, use the **Restart Frigate** button in the UI's Settings menu rather than recreating the container when possible. + + diff --git a/docs/docs/usage/exports.md b/docs/docs/usage/exports.md index a593374b35..885daa06f2 100644 --- a/docs/docs/usage/exports.md +++ b/docs/docs/usage/exports.md @@ -34,7 +34,7 @@ All of your exports live on the **Exports** page, reachable from the main naviga - **Rename** it, and - **Delete** it: deleting is the only way an export is removed. -You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). +You can also select multiple exports at once to **delete** them in bulk, or to **add them to** (or **remove them from**) a [case](#cases). To download multiple exports as a zip archive, add them to a **case** and use the Download button there. ## Cases diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index af3a019b4c..f8519c4b47 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -8244,6 +8244,11 @@ components: properties: provider: $ref: '#/components/schemas/GenAIProviderEnum' + name: + anyOf: + - type: string + - type: 'null' + title: Name api_key: anyOf: - type: string diff --git a/frigate/api/app.py b/frigate/api/app.py index 7f78f4b56c..142d4ee0e4 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -196,7 +196,7 @@ def genai_models(request: Request): "before saving the configuration." ), ) -async def genai_probe(body: GenAIProbeBody): +async def genai_probe(request: Request, body: GenAIProbeBody): load_providers() provider_cls = PROVIDERS.get(body.provider) @@ -206,6 +206,13 @@ async def genai_probe(body: GenAIProbeBody): content={"success": False, "message": "Unknown provider"}, ) + api_key = body.api_key + if api_key == REDACTED_CREDENTIAL_SENTINEL: + saved_cfg = ( + request.app.frigate_config.genai.get(body.name) if body.name else None + ) + api_key = saved_cfg.api_key if saved_cfg else None + # The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via # provider_options; other plugins use GenAIClient.timeout passed below. # Don't inject timeout for Gemini — its HttpOptions interprets the value @@ -217,7 +224,7 @@ async def genai_probe(body: GenAIProbeBody): try: transient_cfg = GenAIConfig( provider=body.provider, - api_key=body.api_key, + api_key=api_key, base_url=body.base_url, provider_options=probe_provider_options, # model is required by the schema but irrelevant for listing. diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index b0b85c7ab9..a331482703 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -14,6 +14,7 @@ class AppConfigSetBody(BaseModel): class GenAIProbeBody(BaseModel): provider: GenAIProviderEnum + name: str | None = None api_key: str | None = None base_url: str | None = None provider_options: dict[str, Any] = Field(default_factory=dict) diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index 4c581dd426..ef5b99ad08 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -132,6 +132,77 @@ class TestHttpApp(BaseTestHttp): "models": ["fake-model-a", "fake-model-b"], } + def test_genai_probe_resolves_sentinel_to_saved_api_key(self): + # After a save the UI's api_key field holds the redaction sentinel; + # the probe must substitute the saved key for the named entry instead + # of sending the literal sentinel to the provider (GH discussion 23754). + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + self.minimal_config["genai"] = { + "llm": { + "provider": "openai", + "api_key": "sk-saved", + "base_url": "https://example.invalid", + "model": "fake-model", + } + } + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + "base_url": "https://example.invalid", + }, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + assert probed_keys == ["sk-saved"] + + def test_genai_probe_sentinel_without_saved_entry_sends_no_key(self): + # If the sentinel arrives for an entry that has no saved config, the + # probe must drop the key entirely rather than leak the sentinel. + probed_keys: list[str | None] = [] + + class CapturingClient(GenAIClient): + def list_models(self): + probed_keys.append(self.genai_config.api_key) + return ["fake-model"] + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: CapturingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "name": "llm", + "api_key": REDACTED_CREDENTIAL_SENTINEL, + }, + ) + assert response.status_code == 200 + assert probed_keys == [None] + def test_genai_probe_empty_list_is_treated_as_failure(self): # The plugin's list_models() returns [] on connection failure rather # than raising. The endpoint should surface that as success=false so diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx index 58a30434b4..52fd3aa57c 100644 --- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx +++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx @@ -160,6 +160,7 @@ export function GenAIModelWidget(props: WidgetProps) { try { const res = await axios.post("genai/probe", { provider: formProvider, + name: providerKey, api_key: typeof formEntry.api_key === "string" ? formEntry.api_key : null, base_url: diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 0b0cb84c13..1add0b09ba 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -444,10 +444,10 @@ export function ExportContent({ } setRange({ - before: latestTime, - after: latestTime - 3600, + before: currentTime + 1800, + after: currentTime - 1800, }); - }, [activeTab, latestTime, range, setRange]); + }, [activeTab, currentTime, range, setRange]); const { data: events, isLoading: isEventsLoading } = useSWR( activeTab === "multi" && debouncedRange @@ -817,7 +817,19 @@ export function ExportContent({ setActiveTab(value as ExportTab)} + onValueChange={(value) => { + const tab = value as ExportTab; + if (tab === "multi") { + setRange({ + before: currentTime + 1800, + after: currentTime - 1800, + }); + } else { + onSelectTime(selectedOption); + } + + setActiveTab(tab); + }} className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")} > From c0cf08ab4aee6c37f68437d0f2e1409b792e2a2f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:44:33 -0500 Subject: [PATCH 8/8] Miscellaneous fixes (0.18 beta) (#23763) --- docker/main/build_sqlite_vec.sh | 2 +- docs/docs/configuration/audio_detectors.md | 4 +- docs/docs/configuration/face_recognition.md | 18 ++- docs/docs/configuration/genai/config.md | 6 +- frigate/api/event.py | 25 ++-- frigate/app.py | 2 +- frigate/db/sqlitevecq.py | 41 +++++- frigate/events/cleanup.py | 9 +- frigate/genai/plugins/llama_cpp.py | 2 +- frigate/test/test_genai_providers.py | 28 ++++ frigate/test/test_sqlitevecq_embeddings.py | 63 +++++++++ .../specs/settings/semantic-search.spec.ts | 129 ++++++++++++++++++ web/public/locales/en/views/settings.json | 3 +- .../config-form/section-configs/ffmpeg.ts | 24 ++++ .../theme/fields/CameraInputsField.tsx | 22 +-- .../widgets/SemanticSearchModelSizeWidget.tsx | 16 ++- 16 files changed, 347 insertions(+), 47 deletions(-) create mode 100644 frigate/test/test_sqlitevecq_embeddings.py create mode 100644 web/e2e/specs/settings/semantic-search.spec.ts diff --git a/docker/main/build_sqlite_vec.sh b/docker/main/build_sqlite_vec.sh index b41f3383d9..8036c6f522 100755 --- a/docker/main/build_sqlite_vec.sh +++ b/docker/main/build_sqlite_vec.sh @@ -2,7 +2,7 @@ set -euxo pipefail -SQLITE_VEC_VERSION="0.1.3" +SQLITE_VEC_VERSION="0.1.9" source /etc/os-release diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index 03c68895bd..b541c86707 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -272,7 +272,7 @@ If you have CUDA hardware, you can experiment with the `large` `whisper` model o #### Transcription and translation of `speech` audio events -Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button in the Tracked Object Details pane. +Any `speech` events in Explore can be transcribed and/or translated through the Transcribe button (the microphone icon) in the Tracked Object Details pane. In order to use transcription and translation for past events, you must enable audio detection and define `speech` as an audio type to listen for. To have `speech` events translated into the language of your choice, set the `language` config parameter with the correct [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10). @@ -294,7 +294,7 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware. - If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control. + If you hear speech that's actually important and worth saving/indexing for the future, **just press the transcribe button (the microphone icon) in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control. Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available. diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 96a29c0a9e..333c3ff1e7 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -232,7 +232,21 @@ Once front-facing images are performing well, start choosing slightly off-angle Start with the [Usage](#usage) section and re-read the [Model Requirements](#model-requirements) above. -1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library. +1. Enable debug logs to see exactly what Frigate is doing. + - Enable debug logs for face recognition by adding `frigate.data_processing.real_time.face: debug` to your `logger` configuration. Restart Frigate after this change. + + ```yaml + logger: + default: info + logs: + # highlight-next-line + frigate.data_processing.real_time.face: debug + ``` + + - These logs report where the pipeline stopped for each `person` object, such as no face being found within the person's bounding box, the detected face being smaller than `min_area`, or a face being recognized but scoring too low. + - If you see no face-related messages at all, also add `frigate.embeddings.maintainer: debug` to confirm that the face processor was created at startup and that `person` updates are reaching it. + +2. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library. If you are using a Frigate+ or `face` detecting model: - Watch the [debug view](/usage/live#the-single-camera-view) to ensure that `face` is being detected along with `person`. @@ -242,7 +256,7 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod - Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects. - You may need to lower your `detection_threshold` if faces are not being detected. -2. Any detected faces will then be _recognized_. +3. Any detected faces will then be _recognized_. - Make sure you have trained at least one face per the recommendations above. - Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration). diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index 738eb5db3f..d1da92c05f 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -78,7 +78,7 @@ All llama.cpp native options can be passed through `provider_options`, including - Set **Provider** to `llamacpp` - Set **Base URL** to your llama.cpp server address (e.g., `http://localhost:8080`) - Set **Model** to the name of your model - - Under **Provider Options**, set `context_size` to tell Frigate your context size so it can send the appropriate amount of information + - Optionally, under **Provider Options**, set `context_size` to override the context size Frigate detects from the server @@ -89,12 +89,14 @@ genai: base_url: http://localhost:8080 model: your-model-name provider_options: - context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information. + context_size: 16000 # Optional, overrides the context size reported by the server. ``` +Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own. + ### Ollama [Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. diff --git a/frigate/api/event.py b/frigate/api/event.py index d37a547ba4..4e99d67035 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1538,15 +1538,18 @@ async def set_description( event.data["description"] = new_description event.save() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: if len(new_description) > 0: - context.update_description( - event_id, - new_description, - ) + # If semantic search is enabled, update the index + if request.app.frigate_config.semantic_search.enabled: + context.update_description( + event_id, + new_description, + ) else: + # embeddings are always cleaned up so they don't outlive their description context.db.delete_embeddings_description(event_ids=[event_id]) response_message = ( @@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict: event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() - # If semantic search is enabled, update the index - if request.app.frigate_config.semantic_search.enabled: - context: EmbeddingsContext = request.app.embeddings + # embeddings are always cleaned up, even when semantic search is disabled, + # so that they don't outlive their events + context: EmbeddingsContext | None = request.app.embeddings + + if context is not None: context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) diff --git a/frigate/app.py b/frigate/app.py index b0e29eab2a..5a39fc8a5b 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -270,7 +270,7 @@ class FrigateApp: 10 * len([c for c in self.config.cameras.values() if c.enabled_in_config]), ), - load_vec_extension=self.config.semantic_search.enabled, + load_vec_extension=True, ) models = [ Event, diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index 137fb51451..2d740f3736 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,9 +1,12 @@ +import logging import sqlite3 from typing import Any import regex from playhouse.sqliteq import SqliteQueueDatabase +logger = logging.getLogger(__name__) + REGEXP_TIMEOUT_SECONDS = 1.0 @@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): def _load_vec_extension(self, conn: sqlite3.Connection) -> None: conn.enable_load_extension(True) - conn.load_extension(self.sqlite_vec_path) - conn.enable_load_extension(False) + + try: + conn.load_extension(self.sqlite_vec_path) + except conn.OperationalError: + logger.error("Unable to load the sqlite-vec extension") + self.load_vec_extension = False + finally: + conn.enable_load_extension(False) def _register_regexp(self, conn: sqlite3.Connection) -> None: def regexp(expr: str, item: str | None) -> bool: @@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): conn.create_function("REGEXP", 2, regexp) - def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + def _delete_embeddings(self, table: str, event_ids: list[str]) -> None: + """Delete embeddings for the given events, if the table exists. + + Embeddings outlive the events they belong to when semantic search is + disabled, so deletes are attempted regardless of the current config. + """ + if not event_ids or not self.load_vec_extension: + return + + # the embeddings tables are only created once semantic search has run + cursor = self.execute_sql( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ) + + if cursor.fetchone() is None: + logger.debug("Skipping %s cleanup, table does not exist", table) + return + ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids) + self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids) + + def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None: + self._delete_embeddings("vec_thumbnails", event_ids) def delete_embeddings_description(self, event_ids: list[str]) -> None: - ids = ",".join(["?" for _ in event_ids]) - self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids) + self._delete_embeddings("vec_descriptions", event_ids) def drop_embeddings_tables(self) -> None: self.execute_sql(""" diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index b867bf947b..88b6a9eda5 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -366,9 +366,10 @@ class EventCleanup(threading.Thread): logger.debug(f"Deleting {len(chunk)} events from the database") Event.delete().where(Event.id << chunk).execute() - if self.config.semantic_search.enabled: - self.db.delete_embeddings_description(event_ids=chunk) - self.db.delete_embeddings_thumbnail(event_ids=chunk) - logger.debug(f"Deleted {len(ids_to_delete)} embeddings") + # embeddings are always cleaned up, even when semantic search + # is disabled, so that they don't outlive their events + self.db.delete_embeddings_description(event_ids=chunk) + self.db.delete_embeddings_thumbnail(event_ids=chunk) + logger.debug(f"Deleted {len(chunk)} embeddings") logger.info("Exiting event cleanup...") diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py index af3ecc9b18..a217e0d898 100644 --- a/frigate/genai/plugins/llama_cpp.py +++ b/frigate/genai/plugins/llama_cpp.py @@ -192,7 +192,7 @@ class LlamaCppClient(GenAIClient): logger.info( "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s", configured_model, - self._context_size or "unknown", + self.get_context_size(), self._supports_vision, self._supports_audio, self._supports_tools, diff --git a/frigate/test/test_genai_providers.py b/frigate/test/test_genai_providers.py index d73d632f0a..5352a81e25 100644 --- a/frigate/test/test_genai_providers.py +++ b/frigate/test/test_genai_providers.py @@ -491,6 +491,34 @@ class TestLlamaCppProvider(unittest.TestCase): final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES)) self.assertEqual(final["content"], "ok") + def _validated_client(self, server_context_size, provider_options=None): + """Build a client as if the server reported the given context size.""" + cfg = GenAIConfig( + provider="llamacpp", + model="m", + base_url="http://localhost:9999", + provider_options=provider_options or {}, + ) + info = { + "context_size": server_context_size, + "supports_vision": False, + "supports_audio": False, + "supports_tools": False, + "supports_reasoning": False, + "media_marker": "<__media__>", + } + cls = PROVIDERS[GenAIProviderEnum.llamacpp] + with patch.object(cls, "_get_model_info", return_value=info): + return cls(cfg, timeout=5) + + def test_server_context_size_used_without_override(self): + client = self._validated_client(4096) + self.assertEqual(client.get_context_size(), 4096) + + def test_provider_options_context_size_overrides_server(self): + client = self._validated_client(4096, {"context_size": 32768}) + self.assertEqual(client.get_context_size(), 32768) + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_sqlitevecq_embeddings.py b/frigate/test/test_sqlitevecq_embeddings.py new file mode 100644 index 0000000000..dc80cd96f8 --- /dev/null +++ b/frigate/test/test_sqlitevecq_embeddings.py @@ -0,0 +1,63 @@ +"""Tests for embedding cleanup on the main Frigate database. + +Embeddings are deleted whether or not semantic search is currently enabled, so +the delete path has to tolerate databases where the vec0 tables were never +created and installs where the sqlite-vec extension is unavailable. +""" + +import os +import tempfile +import unittest + +from frigate.db.sqlitevecq import SqliteVecQueueDatabase + + +class TestDeleteEmbeddings(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db")) + self.db.start() + # the extension is not available to tests, so stand in for a database + # that has it loaded and use a plain table for the deletes + self.db.load_vec_extension = True + + def tearDown(self) -> None: + self.db.stop() + self.db.close() + self.tmp_dir.cleanup() + + def _flush_writes(self) -> None: + # writes are queued and applied by a worker thread, and the queue is + # FIFO, so awaiting a later write means the earlier ones are done + self.db.execute_sql("PRAGMA user_version = 0").fetchall() + + def _create_thumbnails_table(self) -> None: + self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)") + self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')") + self._flush_writes() + + def _thumbnail_ids(self) -> list[str]: + return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")] + + def test_delete_without_tables_does_not_raise(self) -> None: + # semantic search was never enabled, so event cleanup has nothing to do + self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"]) + self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"]) + + def test_delete_removes_embeddings(self) -> None: + self._create_thumbnails_table() + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + self.assertEqual(self._thumbnail_ids(), ["b"]) + + def test_delete_skipped_without_extension(self) -> None: + self._create_thumbnails_table() + self.db.load_vec_extension = False + + self.db.delete_embeddings_thumbnail(event_ids=["a"]) + self._flush_writes() + + # the vec0 tables cannot be written without the extension + self.assertEqual(self._thumbnail_ids(), ["a", "b"]) diff --git a/web/e2e/specs/settings/semantic-search.spec.ts b/web/e2e/specs/settings/semantic-search.spec.ts new file mode 100644 index 0000000000..b7074800a2 --- /dev/null +++ b/web/e2e/specs/settings/semantic-search.spec.ts @@ -0,0 +1,129 @@ +/** + * Semantic Search settings tests -- MEDIUM tier. + * + * Focuses on the model_size field, which is unused when a GenAI embeddings + * provider is selected as the semantic search model. The resolved config always + * reports model_size (it has a schema default of "small"), even when the YAML + * file has no such key. Clearing model_size for a provider used to run + * unconditionally, which falsely marked the section dirty on load and asked the + * backend to delete a key that wasn't in the config file (KeyError: 'model_size'). + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; +import { configFactory } from "../../fixtures/mock-data/config"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_SCHEMA = JSON.parse( + readFileSync( + resolve(__dirname, "../../fixtures/mock-data/config-schema.json"), + "utf-8", + ), +); + +const PROVIDER = "llama_cpp"; +const SETTINGS_URL = "/settings?page=integrationSemanticSearch"; +const NOT_APPLICABLE = "Not applicable for GenAI providers"; +const UNSAVED = "You have unsaved changes"; + +type SemanticSearch = { + enabled?: boolean; + model?: string; + model_size?: string; +}; + +async function installRoutes(page: Page, semanticSearch: SemanticSearch) { + const config = configFactory({ + genai: { [PROVIDER]: { provider: PROVIDER, roles: ["embeddings"] } }, + semantic_search: semanticSearch, + }); + + let lastSavedConfig: unknown = null; + + await page.route("**/api/config/schema.json", (route) => + route.fulfill({ json: CONFIG_SCHEMA }), + ); + await page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: config }); + } + return route.fulfill({ json: { success: true } }); + }); + await page.route("**/api/config/set", async (route) => { + lastSavedConfig = route.request().postDataJSON(); + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ json: { semantic_search: semanticSearch } }), + ); + + return { capturedConfig: () => lastSavedConfig }; +} + +test.describe("semantic search model_size @medium", () => { + test("a provider with a defaulted model_size is not dirty on load", async ({ + frigateApp, + }) => { + // model_size stays at its schema default ("small"), i.e. it is not present + // in the YAML. This mirrors the reported bug: selecting a GenAI provider and + // returning to the page. + await installRoutes(frigateApp.page, { + enabled: true, + model: PROVIDER, + }); + await frigateApp.goto(SETTINGS_URL); + + // The provider path is active: model_size shows "Not applicable". + await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible(); + + // Give any clearing effect time to fire, then confirm the section stayed + // clean (no phantom unsaved-changes banner, Save disabled). + await frigateApp.page.waitForTimeout(1000); + await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden(); + await expect( + frigateApp.page.getByRole("button", { name: "Save", exact: true }), + ).toBeDisabled(); + }); + + test("switching from a configured non-default model_size clears it", async ({ + frigateApp, + }) => { + // A genuinely configured non-default model_size ("large") can only come from + // the YAML, so switching to a provider must still remove it. + const capture = await installRoutes(frigateApp.page, { + enabled: true, + model: "jinav2", + model_size: "large", + }); + await frigateApp.goto(SETTINGS_URL); + + // Starts clean on a Jina model. + await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden(); + + // Switch the model to the GenAI provider. + await frigateApp.page + .getByRole("combobox", { name: /Semantic search model/ }) + .click(); + await frigateApp.page.getByRole("option", { name: PROVIDER }).click(); + + // The change is now dirty and model_size is no longer applicable. + await expect(frigateApp.page.getByText(NOT_APPLICABLE)).toBeVisible(); + await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible(); + + await frigateApp.page + .getByRole("button", { name: "Save", exact: true }) + .click(); + + // The saved payload removes model_size (empty string = "remove" key). + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + semantic_search: { model: PROVIDER, model_size: "" }, + }, + }); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 68248241ce..165569638d 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1936,7 +1936,8 @@ "inputDimensionsNotDetectResolution": "Model input width and height are the input dimensions of the object detection model, not your camera's detect resolution. They should match the dimensions of the model you're using — typically a square size like 320x320 or 640x640." }, "ffmpeg": { - "hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware." + "hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware.", + "inputsMissingGo2rtcStream": "An input below points at a go2rtc restream that no longer exists. Select an existing restream or enter the camera's URL manually, otherwise this camera will fail to connect." }, "objects": { "genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated." diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index 6f9cffad9e..62117d1b4b 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -1,3 +1,4 @@ +import { parseRestreamStreamName } from "../theme/fields/streamSource"; import type { SectionConfigOverrides } from "./types"; const arrayAsTextWidget = { @@ -42,6 +43,29 @@ const ffmpeg: SectionConfigOverrides = { return false; }, }, + { + key: "inputs-missing-go2rtc-stream", + field: "inputs", + position: "before", + messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream", + severity: "warning", + docLink: "/configuration/restream", + condition: (ctx) => { + const streams = ctx.fullConfig?.go2rtc?.streams; + const inputs = ctx.formData?.inputs; + if (!Array.isArray(inputs)) { + return false; + } + + return inputs.some((input) => { + const path = (input as { path?: unknown } | null)?.path; + const streamName = parseRestreamStreamName( + typeof path === "string" ? path : undefined, + ); + return streamName !== undefined && !(streamName in (streams ?? {})); + }); + }, + }, ], fieldDocs: { hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets", diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx index 205e888c9e..6d42de613d 100644 --- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx +++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx @@ -170,6 +170,12 @@ export function CameraInputsField(props: FieldProps) { [go2rtcStreamNames], ); + useEffect(() => { + setSourceModeByIndex((previous) => + Object.keys(previous).length > 0 ? {} : previous, + ); + }, [formContext?.cameraName]); + useEffect(() => { setOpenByIndex((previous) => { const next: Record = {}; @@ -222,18 +228,12 @@ export function CameraInputsField(props: FieldProps) { const handleSourceModeChange = useCallback( (index: number, nextMode: StreamSourceMode) => { const input = inputs[index]; - const currentPath = - typeof input?.path === "string" ? input.path : undefined; - if (nextMode === "manual") { - // Only revert the preset we set ourselves; never clobber custom args. - if (input?.input_args === RESTREAM_PRESET) { - handleFieldValuesChange(index, { input_args: undefined }); - } - } else if (!parseRestreamStreamName(currentPath)) { - // Entering restream with a non-restream path: clear it so the dropdown - // shows its placeholder until a stream is chosen. - handleFieldValuesChange(index, { path: undefined }); + // Only revert the preset we set ourselves; never clobber custom args. + // The path is left alone until a stream is picked, so switching modes + // never discards a typed URL or empties a required field. + if (nextMode === "manual" && input?.input_args === RESTREAM_PRESET) { + handleFieldValuesChange(index, { input_args: undefined }); } setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode })); diff --git a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx index 4ee0019363..6e8f383cae 100644 --- a/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx +++ b/web/src/components/config-form/theme/widgets/SemanticSearchModelSizeWidget.tsx @@ -24,15 +24,19 @@ export function SemanticSearchModelSizeWidget(props: WidgetProps) { model !== "jinav1" && model !== "jinav2"; - // Clear model_size while on a provider (buildOverrides converts to "" - // which the backend treats as "remove"). Restore the schema default - // when returning to a Jina model so the field isn't left empty. + // model_size is unused on a GenAI provider. Only clear it (which the backend + // treats as "remove") for a non-default value, which can only come from the + // config file. A defaulted value is indistinguishable from unset in the + // resolved config, so clearing it would falsely dirty the field and delete a + // YAML key that isn't there. Restore the default when returning to a Jina model. const { value, onChange, schema } = props; const schemaDefault = schema?.default as string | undefined; useEffect(() => { - if (isProvider && value !== undefined) { - onChange(undefined); - } else if (!isProvider && value === undefined && schemaDefault) { + if (isProvider) { + if (value !== undefined && value !== schemaDefault) { + onChange(undefined); + } + } else if (value === undefined && schemaDefault) { onChange(schemaDefault); } }, [isProvider, value, onChange, schemaDefault]);