Miscellaneous fixes (0.18 beta) (#23854)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* fix watchdog process restarts reverting to the boot config

/api/config/set parses a new FrigateConfig and swaps the API and dispatcher onto it, but FrigateApp.config was never rebound, so the watchdog factories rebuilt a crashed process from the config as of startup. Fix is to read through a ConfigHolder that the swap updates.

* fix birdseye camera overrides being clobbered by a global mode change

A global birdseye save published only the global object, leaving the output process to infer which cameras were inheriting by comparing against the previous global mode. That cannot tell an inherited value from an explicit one that happens to match, so it overwrote the override until a restart. Publish the per-camera values the config parse already resolved instead.

* Reject non-finite numbers in GenAI review descriptions

A model returning NaN for confidence or potential_threat_level slipped through the model_construct fallback, which skips validation, and was written into the review segment's JSON data. NaN is not valid JSON, so every subsequent /review request failed with "Out of range float values are not JSON compliant", blanking the review page for any time range containing the poisoned row.

* restore fused DetectionOutput in the OpenVINO SSD model conversion

* fix rgb swap issue for face dataset testing script
This commit is contained in:
Josh Hawkins
2026-07-29 08:39:01 -06:00
committed by GitHub
parent 860772f9f4
commit 7ed7ed56cf
12 changed files with 386 additions and 65 deletions
+15 -1
View File
@@ -31,7 +31,10 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.config_util import swap_runtime_config
from frigate.api.config_util import (
publish_camera_section_updates,
swap_runtime_config,
)
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import (
AppConfigSetBody,
@@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody):
body.update_topic, settings
)
# a config/cameras/* topic publishes camera copies, a
# global topic the global object. FrigateConfig.parse
# folds some global sections down into every camera,
# and workers read both objects, so any such section
# needs its camera copies sent alongside the global
# publish above.
if body.update_topic == "config/birdseye":
publish_camera_section_updates(
request.app, config, CameraConfigUpdateEnum.birdseye
)
return JSONResponse(
content=(
{
+28
View File
@@ -3,6 +3,30 @@
from fastapi import FastAPI
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
def publish_camera_section_updates(
app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum
) -> None:
"""Broadcast every camera's re-resolved value for a global section.
Global sections are folded into each camera at parse time and the camera
copies are what workers read, so send them rather than leave a worker to
guess which cameras were inheriting.
"""
for camera_name, camera_config in config.cameras.items():
settings = getattr(camera_config, update_type.name, None)
if settings is None:
continue
app.config_publisher.publish_update(
CameraConfigUpdateTopic(update_type, camera_name), settings
)
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
@@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
camera the user turned off would silently come back on.
"""
app.frigate_config = config
if app.config_holder is not None:
app.config_holder.set(config)
app.genai_manager.update_config(config)
if app.profile_manager is not None:
+3
View File
@@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import (
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext
@@ -74,6 +75,7 @@ def create_fastapi_app(
dispatcher: Dispatcher | None = None,
profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True,
config_holder: ConfigHolder | None = None,
):
logger.info("Starting FastAPI app")
app = FastAPI(
@@ -162,6 +164,7 @@ def create_fastapi_app(
app.replay_manager = replay_manager
app.dispatcher = dispatcher
app.profile_manager = profile_manager
app.config_holder = config_holder
if frigate_config.auth.enabled:
secret = get_jwt_secret()
+14 -1
View File
@@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.config import FrigateConfig
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager
from frigate.const import (
CACHE_DIR,
@@ -122,7 +123,18 @@ class FrigateApp:
self.processes: dict[str, int] = {}
self.embeddings: EmbeddingsContext | None = None
self.profile_manager: ProfileManager | None = None
self.config = config
self.config_holder = ConfigHolder(config)
@property
def config(self) -> FrigateConfig:
"""The current config, not the one Frigate booted with.
Read through the holder so the deferred watchdog factories below build
a replacement process from the config as it is now. There is no setter
on purpose: a plain attribute would let a caller pin this back to a
single object and reintroduce the staleness.
"""
return self.config_holder.config
def ensure_dirs(self) -> None:
dirs = [
@@ -645,6 +657,7 @@ class FrigateApp:
self.replay_manager,
self.dispatcher,
self.profile_manager,
config_holder=self.config_holder,
),
host="127.0.0.1",
port=5001,
+34
View File
@@ -0,0 +1,34 @@
"""Shared handle on the config object that is current for this instance."""
from .config import FrigateConfig
__all__ = ["ConfigHolder"]
class ConfigHolder:
"""Indirection for the most recently parsed config.
/api/config/set re-parses yaml into a brand new FrigateConfig instead of
mutating the old one, so any reference captured during startup goes stale
the first time a user saves. Anything that has to build something after
startup, most importantly the watchdog factories that rebuild a crashed
process, must read through a holder rather than close over a config
object, or the rebuilt process comes back with the config as it was at
boot and silently discards every change made since.
There is deliberately no setter on the read side: the swap runs in exactly
one place (frigate.api.config_util.swap_runtime_config) and everyone else
only reads.
"""
def __init__(self, config: FrigateConfig) -> None:
self._config = config
@property
def config(self) -> FrigateConfig:
"""The config as of the most recent successful save."""
return self._config
def set(self, config: FrigateConfig) -> None:
"""Install a freshly parsed config as the current one."""
self._config = config
+10
View File
@@ -23,6 +23,7 @@ from frigate.genai.prompts import (
build_review_summary_prompt,
)
from frigate.models import Event
from frigate.util.builtin import has_non_finite_number
logger = logging.getLogger(__name__)
@@ -164,6 +165,15 @@ class GenAIClient:
except json.JSONDecodeError as je:
logger.error("Failed to parse review description JSON: %s", je)
return None
# model_construct skips validation, so non-finite numbers that
# the validated path would have rejected have to be caught here
if has_non_finite_number(raw):
logger.error(
"Discarding review description containing non-finite numbers."
)
return None
# observations and confidence are required on the model; fill an empty default
# if the response omitted it so attribute access stays safe.
raw.setdefault("observations", [])
+3 -6
View File
@@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess):
)
if update_topic is not None and birdseye_config is not None:
previous_global_mode = self.config.birdseye.mode
# only the global-only fields are applied here; the per-camera
# enabled and mode arrive on config/cameras/<name>/birdseye,
# already resolved against yaml by the config parse
self.config.birdseye = birdseye_config
for camera_config in self.config.cameras.values():
if camera_config.birdseye.mode == previous_global_mode:
camera_config.birdseye.mode = birdseye_config.mode
logger.debug("Applied dynamic birdseye config update")
# check if there is an updated config
@@ -13,6 +13,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.config.holder import ConfigHolder
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_global_birdseye_save_fans_out_resolved_camera_configs(
self, mock_find_config
):
"""A global birdseye save must also publish the per-camera values.
Global birdseye only seeds enabled and mode; the camera copies are what
the output process actually reads. Sending just the global object makes
a worker guess which cameras were inheriting, and the only available
guess (mode still equals the previous global) wrongly claims a camera
whose explicit yaml mode happens to match.
"""
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
# explicit override that matches the global value being replaced
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"mode": "continuous"}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# the global object still goes out on its own topic
mock_publisher.publisher.publish.assert_called_once()
topic, settings = mock_publisher.publisher.publish.call_args[0]
self.assertEqual(topic, "config/birdseye")
self.assertEqual(settings.mode.value, "continuous")
published = {
call[0][0].camera: call[0][1]
for call in mock_publisher.publish_update.call_args_list
}
self.assertEqual(set(published), {"front_door", "back_yard"})
for call in mock_publisher.publish_update.call_args_list:
self.assertEqual(
call[0][0].update_type, CameraConfigUpdateEnum.birdseye
)
# the override survives, the inheriting camera follows global
self.assertEqual(published["front_door"].mode.value, "motion")
self.assertEqual(published["back_yard"].mode.value, "continuous")
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_save_updates_the_config_holder(self, mock_find_config):
"""A save must move the holder onto the freshly parsed config.
FrigateApp reads the holder when the watchdog rebuilds a crashed
process; if the save leaves it on the boot config, that process comes
back having lost every change made since Frigate started.
"""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
boot_config = FrigateConfig(**self.minimal_config)
holder = ConfigHolder(boot_config)
try:
app = create_fastapi_app(
boot_config,
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
config_holder=holder,
)
async def mock_get_current_user(request: Request):
return {"username": "admin", "role": "admin"}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"inactivity_threshold": 5}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertIsNot(holder.config, boot_config)
self.assertIs(holder.config, app.frigate_config)
self.assertEqual(holder.config.birdseye.inactivity_threshold, 5)
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config
from frigate.config.holder import ConfigHolder
class TestSwapRuntimeConfig(unittest.TestCase):
@@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
def _make_app(self) -> MagicMock:
app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()]
app.config_holder = ConfigHolder(MagicMock(name="boot_config"))
return app
def test_rebinds_all_references(self) -> None:
@@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase):
# the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_updates_the_config_holder(self) -> None:
app = self._make_app()
holder = app.config_holder
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(holder.config, config)
def test_deferred_factory_builds_from_the_swapped_config(self) -> None:
"""A watchdog-style factory must not rebuild from the boot config.
The factories in FrigateApp are lambdas evaluated when a process is
restarted, long after a user may have saved. Reading through the
holder is what keeps a rebuilt process from reverting every change
made since Frigate started.
"""
app = self._make_app()
holder = app.config_holder
boot_config = holder.config
factory = lambda: holder.config # noqa: E731
self.assertIs(factory(), boot_config)
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(factory(), config)
def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock()
app.profile_manager = None
app.stats_emitter = None
app.dispatcher = None
app.config_holder = None
config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent
+12
View File
@@ -472,6 +472,18 @@ def sanitize_float(value):
return value
def has_non_finite_number(value: Any) -> bool:
"""Return True if any number in a parsed JSON value is NaN or infinite."""
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(has_non_finite_number(v) for v in value.values())
if isinstance(value, list):
return any(has_non_finite_number(v) for v in value)
return False
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return 1 - cosine_distance(a, b)