mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-10 12:51:11 +03:00
Miscellaneous fixes (0.18 beta) (#23898)
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
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
* update homekit docs * update dictionary * preserve function names in production builds adds only 162kb gzipped/450k unzipped to the bundle * margin tweak * fix maximum update depth exceeded when dragging the timeline handlebar Dragging the handlebar, especially quickly or with fast direction changes, could exceed React's nested update limit and unmount the whole app, leaving a blank screen. Motion search was worst affected. The drag loop committed a new time into React state on every animation frame. Edge auto-scrolling mutates scrollTop each iteration, so the value always differed and React's same-value bail-out never engaged, letting the update chain run to the limit of 50. Pace those commits to one per 100ms and flush the pending value on release, so the drop position is still exact. The handlebar position and label are written to the DOM directly and remain at frame rate. useUserInteraction dispatched state on every scroll and touchmove event; only commit on the leading edge. Motion search also passed fresh array literals for the timeline's events, motion events and unavailable ranges, giving the segment memo and the drag effect new dependencies on every render. Both views also passed an inline arrow for onHandlebarDraggingChange, which is an effect dependency that calls setState. * Verify motion search jobs belong to the requested camera * Apply persisted profile and runtime overrides before workers start Worker processes are handed a copy of the config when they start and only learn about later changes from the config_updater broadcast, which is plain ZMQ PUB/SUB with no queue, ack, or retained value, so a message published before a subscriber has connected is dropped and never re-sent. The persisted profile and the runtime camera toggles were restored only by that broadcast, at the very end of startup, so a worker that lost the race kept its yaml values for the rest of the session: audio detection kept running on a camera whose audio had been toggled off, even though /api/config, the UI, and the runtime state file all showed it disabled. Split both restores into a config half and a publish half. ProfileManager.restore_persisted_profile_to_config() and Dispatcher.reapply_runtime_state_to_config() now run right after init_profile_manager(), before the first worker starts, so every worker is handed a config that already carries both layers. ProfileManager.restore_persisted_profile() and Dispatcher.restore_runtime_state() still run at the end of startup: the recording, review, and embeddings processes start before the dispatcher exists, so the broadcast remains their only channel, and MQTT needs the retained switch states. Both config passes have to stay after init_profile_manager(), which snapshots the config as the no-profile base that deactivation resets to. * End timeline drags on touchcancel
This commit is contained in:
@@ -182,7 +182,7 @@ async def get_motion_search_status_endpoint(
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
if not job or job.camera != camera_name:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
@@ -253,7 +253,7 @@ async def cancel_motion_search_endpoint(
|
||||
)
|
||||
|
||||
job = get_motion_search_job(job_id)
|
||||
if not job:
|
||||
if not job or job.camera != camera_name:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Job not found"},
|
||||
status_code=404,
|
||||
|
||||
+10
-22
@@ -122,7 +122,6 @@ class FrigateApp:
|
||||
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
||||
self.processes: dict[str, int] = {}
|
||||
self.embeddings: EmbeddingsContext | None = None
|
||||
self.profile_manager: ProfileManager | None = None
|
||||
self.config_holder = ConfigHolder(config)
|
||||
|
||||
@property
|
||||
@@ -355,25 +354,6 @@ class FrigateApp:
|
||||
)
|
||||
self.dispatcher.profile_manager = self.profile_manager
|
||||
|
||||
def restore_active_profile(self) -> None:
|
||||
"""Re-activate the persisted profile after subscribers are connected.
|
||||
|
||||
ZMQ PUB/SUB drops messages with no subscribers, so activation must
|
||||
run after every config_updater subscriber is up.
|
||||
"""
|
||||
if self.profile_manager is None:
|
||||
return
|
||||
|
||||
persisted = ProfileManager.load_persisted_profile()
|
||||
if persisted and any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
# runtime overrides are layered on top via restore_runtime_state()
|
||||
self.profile_manager.activate_profile(
|
||||
persisted, clear_runtime_overrides=False
|
||||
)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
for name in self.config.cameras.keys():
|
||||
try:
|
||||
@@ -622,6 +602,13 @@ class FrigateApp:
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
self.init_profile_manager()
|
||||
|
||||
# workers get a copy of the config and can miss the broadcast below, so
|
||||
# apply both layers here. must stay after init_profile_manager(), which
|
||||
# snapshots the base config that profile deactivation resets to
|
||||
self.profile_manager.restore_persisted_profile_to_config()
|
||||
self.dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.init_embeddings_client()
|
||||
self.start_video_output_processor()
|
||||
self.start_ptz_autotracker()
|
||||
@@ -636,8 +623,9 @@ class FrigateApp:
|
||||
self.start_record_cleanup()
|
||||
self.start_watchdog()
|
||||
|
||||
# restore persisted runtime overrides on top of config
|
||||
self.restore_active_profile()
|
||||
# publish for the recording/review/embeddings processes, which start
|
||||
# before the config can be corrected, and for the retained MQTT states
|
||||
self.profile_manager.restore_persisted_profile()
|
||||
self.dispatcher.restore_runtime_state()
|
||||
|
||||
self.init_auth()
|
||||
|
||||
@@ -169,6 +169,93 @@ class ProfileManager:
|
||||
self.config.active_profile = None
|
||||
self._persist_active_profile(None)
|
||||
|
||||
def _validate_profile_name(self, profile_name: str | None) -> str | None:
|
||||
"""Return an error message if the name is not a defined profile."""
|
||||
if profile_name is not None and profile_name not in self.config.profiles:
|
||||
return f"Profile '{profile_name}' is not defined in the profiles section"
|
||||
|
||||
return None
|
||||
|
||||
def _apply_to_config(
|
||||
self, profile_name: str | None
|
||||
) -> tuple[dict[str, set[str]], str | None]:
|
||||
"""Reset every camera to base, then apply the named profile on top.
|
||||
|
||||
Returns the changed camera/section pairs, plus an error message if
|
||||
applying the profile failed partway through.
|
||||
"""
|
||||
changed: dict[str, set[str]] = {}
|
||||
|
||||
self._reset_to_base(changed)
|
||||
|
||||
if profile_name is not None:
|
||||
err = self._apply_profile_overrides(profile_name, changed)
|
||||
if err:
|
||||
return changed, err
|
||||
|
||||
return changed, None
|
||||
|
||||
def apply_profile_to_config(self, profile_name: str | None) -> str | None:
|
||||
"""Apply a profile to the in-memory config, without publishing it.
|
||||
|
||||
Safe to call ahead of activate_profile: both reset to the base config
|
||||
first, so the later call re-derives the same state and still reports
|
||||
every section as changed.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
err = self._validate_profile_name(profile_name)
|
||||
|
||||
if err:
|
||||
return err
|
||||
|
||||
return self._apply_to_config(profile_name)[1]
|
||||
|
||||
def _persisted_profile_to_restore(self) -> str | None:
|
||||
"""Return the persisted profile name, if it still applies to a camera."""
|
||||
persisted = self.load_persisted_profile()
|
||||
|
||||
if not persisted or not any(
|
||||
persisted in cam.profiles for cam in self.config.cameras.values()
|
||||
):
|
||||
return None
|
||||
|
||||
return persisted
|
||||
|
||||
def restore_persisted_profile_to_config(self) -> None:
|
||||
"""Restore the persisted profile into the config, without publishing.
|
||||
|
||||
Called before worker processes start, so they are handed a config that
|
||||
already carries the profile rather than relying on the broadcast that
|
||||
restore_persisted_profile() sends later.
|
||||
"""
|
||||
persisted = self._persisted_profile_to_restore()
|
||||
|
||||
if persisted is None:
|
||||
return
|
||||
|
||||
err = self.apply_profile_to_config(persisted)
|
||||
|
||||
if err:
|
||||
logger.error("Failed to apply persisted profile '%s': %s", persisted, err)
|
||||
|
||||
def restore_persisted_profile(self) -> None:
|
||||
"""Re-activate the persisted profile once subscribers are connected.
|
||||
|
||||
The config already carries the profile; this pass publishes it for the
|
||||
processes that start before the config can be corrected, and for the
|
||||
retained MQTT states.
|
||||
"""
|
||||
persisted = self._persisted_profile_to_restore()
|
||||
|
||||
if persisted is None:
|
||||
return
|
||||
|
||||
logger.info("Restoring persisted profile '%s'", persisted)
|
||||
# runtime overrides are layered on top by the dispatcher's replay
|
||||
self.activate_profile(persisted, clear_runtime_overrides=False)
|
||||
|
||||
def activate_profile(
|
||||
self,
|
||||
profile_name: str | None,
|
||||
@@ -187,23 +274,16 @@ class ProfileManager:
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
if profile_name is not None:
|
||||
if profile_name not in self.config.profiles:
|
||||
return (
|
||||
f"Profile '{profile_name}' is not defined in the profiles section"
|
||||
)
|
||||
err = self._validate_profile_name(profile_name)
|
||||
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Track which camera/section pairs get changed for ZMQ publishing
|
||||
changed: dict[str, set[str]] = {}
|
||||
changed, err = self._apply_to_config(profile_name)
|
||||
|
||||
# Reset all cameras to base config
|
||||
self._reset_to_base(changed)
|
||||
|
||||
# Apply new profile overrides if activating
|
||||
if profile_name is not None:
|
||||
err = self._apply_profile_overrides(profile_name, changed)
|
||||
if err:
|
||||
return err
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Publish ZMQ updates only for sections that actually changed
|
||||
self._publish_updates(changed)
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.app import FrigateApp
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
|
||||
@@ -363,5 +364,94 @@ class TestReapplyRuntimeStateToConfig(unittest.TestCase):
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
|
||||
class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
|
||||
"""Both layers must reach the config before config-carrying workers start.
|
||||
|
||||
A worker started before a layer is applied keeps the yaml value for the
|
||||
rest of the session: the config_updater broadcast sent later is dropped
|
||||
for subscribers that have not connected yet, and nothing re-sends it.
|
||||
"""
|
||||
|
||||
CONFIG_LAYERS = (
|
||||
"profile_manager.restore_persisted_profile_to_config",
|
||||
"dispatcher.reapply_runtime_state_to_config",
|
||||
)
|
||||
|
||||
# started with a copy of the camera config
|
||||
CONFIG_CARRYING_WORKERS = (
|
||||
"start_video_output_processor",
|
||||
"start_ptz_autotracker",
|
||||
"start_detected_frames_processor",
|
||||
"start_camera_processor",
|
||||
"start_audio_processor",
|
||||
)
|
||||
|
||||
def _start_call_order(self) -> list[str]:
|
||||
"""Return the names FrigateApp.start() calls, in order."""
|
||||
app = MagicMock()
|
||||
|
||||
with (
|
||||
patch("frigate.app.set_file_limit"),
|
||||
patch("frigate.app.cleanup_replay_cameras"),
|
||||
patch("frigate.app.reap_stale_exports"),
|
||||
patch("frigate.app.create_fastapi_app"),
|
||||
patch("frigate.app.uvicorn"),
|
||||
):
|
||||
FrigateApp.start(app)
|
||||
|
||||
return [name for name, _, _ in app.mock_calls]
|
||||
|
||||
def test_applied_before_any_config_carrying_worker(self) -> None:
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
for worker in self.CONFIG_CARRYING_WORKERS:
|
||||
self.assertLess(order.index(layer), order.index(worker))
|
||||
|
||||
def test_applied_after_the_dispatcher_exists(self) -> None:
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
self.assertLess(order.index("init_dispatcher"), order.index(layer))
|
||||
|
||||
def test_applied_after_the_profile_base_is_snapshotted(self) -> None:
|
||||
# ProfileManager snapshots the config as the "no profile" base that
|
||||
# deactivation resets to, so neither layer may be in the config yet
|
||||
order = self._start_call_order()
|
||||
|
||||
for layer in self.CONFIG_LAYERS:
|
||||
self.assertLess(order.index("init_profile_manager"), order.index(layer))
|
||||
|
||||
def test_layers_applied_in_order(self) -> None:
|
||||
# a runtime toggle is the layer the user set last, so it goes on top
|
||||
order = self._start_call_order()
|
||||
|
||||
self.assertLess(
|
||||
order.index("profile_manager.restore_persisted_profile_to_config"),
|
||||
order.index("dispatcher.reapply_runtime_state_to_config"),
|
||||
)
|
||||
|
||||
def test_overrides_still_re_applied_after_the_profile_is_restored(self) -> None:
|
||||
# activation resets the sections it owns to the base first, so the
|
||||
# overrides have to land on top again
|
||||
order = self._start_call_order()
|
||||
|
||||
self.assertLess(
|
||||
order.index("profile_manager.restore_persisted_profile"),
|
||||
order.index("dispatcher.restore_runtime_state"),
|
||||
)
|
||||
|
||||
def test_broadcast_replay_still_runs_at_the_end(self) -> None:
|
||||
# the broadcast is the only channel for the recording, review, and
|
||||
# embeddings processes, which start before the config can be corrected
|
||||
order = self._start_call_order()
|
||||
|
||||
for replay in (
|
||||
"profile_manager.restore_persisted_profile",
|
||||
"dispatcher.restore_runtime_state",
|
||||
):
|
||||
self.assertLess(order.index("start_audio_processor"), order.index(replay))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -785,6 +785,98 @@ class TestProfileManager(unittest.TestCase):
|
||||
manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
def test_apply_profile_to_config_mutates_the_config(self):
|
||||
"""The config-only half applies the same overrides as activation."""
|
||||
err = self.manager.apply_profile_to_config("armed")
|
||||
assert err is None
|
||||
|
||||
front = self.config.cameras["front"]
|
||||
assert front.notifications.enabled is True
|
||||
assert front.objects.track == ["person", "car", "package"]
|
||||
|
||||
def test_apply_profile_to_config_makes_no_zmq_mqtt_or_disk_writes(self):
|
||||
"""Workers are started with the values, so nothing is published yet."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
|
||||
with patch.object(ProfileManager, "_persist_active_profile") as mock_persist:
|
||||
manager.apply_profile_to_config("armed")
|
||||
|
||||
self.mock_updater.publish_update.assert_not_called()
|
||||
dispatcher.publish.assert_not_called()
|
||||
mock_persist.assert_not_called()
|
||||
# bookkeeping stays with activate_profile
|
||||
assert self.config.active_profile is None
|
||||
|
||||
def test_apply_profile_to_config_rejects_an_unknown_profile(self):
|
||||
err = self.manager.apply_profile_to_config("nonexistent")
|
||||
assert err is not None
|
||||
assert "not defined" in err
|
||||
|
||||
def test_restore_persisted_profile_to_config_applies_it(self):
|
||||
"""The startup config pass restores what was persisted."""
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||
):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is True
|
||||
# still the config-only half, so nothing is published or persisted
|
||||
self.mock_updater.publish_update.assert_not_called()
|
||||
assert self.config.active_profile is None
|
||||
|
||||
def test_restore_persisted_profile_to_config_no_op_when_none_persisted(self):
|
||||
with patch.object(ProfileManager, "load_persisted_profile", return_value=None):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is False
|
||||
|
||||
def test_restore_persisted_profile_to_config_ignores_a_stale_name(self):
|
||||
"""A profile no longer offered by any camera must not be applied."""
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="ghost"
|
||||
):
|
||||
self.manager.restore_persisted_profile_to_config()
|
||||
|
||||
assert self.config.cameras["front"].notifications.enabled is False
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_restore_persisted_profile_activates_and_publishes(self, mock_persist):
|
||||
"""The startup publish pass runs a full activation."""
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
|
||||
|
||||
with patch.object(
|
||||
ProfileManager, "load_persisted_profile", return_value="armed"
|
||||
):
|
||||
manager.restore_persisted_profile()
|
||||
|
||||
assert self.config.active_profile == "armed"
|
||||
self.mock_updater.publish_update.assert_called()
|
||||
# a startup replay must not wipe the runtime overrides layered on top
|
||||
dispatcher.clear_runtime_state.assert_not_called()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_activation_after_apply_still_publishes_every_section(self, mock_persist):
|
||||
"""Re-deriving the same state must not skip the broadcast.
|
||||
|
||||
The processes that started before the config was corrected have no
|
||||
other channel.
|
||||
"""
|
||||
self.manager.apply_profile_to_config("armed")
|
||||
self.mock_updater.publish_update.reset_mock()
|
||||
|
||||
err = self.manager.activate_profile("armed", clear_runtime_overrides=False)
|
||||
assert err is None
|
||||
|
||||
published = {
|
||||
call.args[0].update_type.name
|
||||
for call in self.mock_updater.publish_update.call_args_list
|
||||
}
|
||||
assert "notifications" in published
|
||||
assert "objects" in published
|
||||
assert self.config.active_profile == "armed"
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_update_config_preserves_runtime_state_with_active_profile(
|
||||
self, mock_persist
|
||||
|
||||
Reference in New Issue
Block a user