From 570e21340a14658d75ce7d8dcac0b4a7e65226e0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:55:52 -0500 Subject: [PATCH] Miscellaneous fixes (#23373) * republish MQTT switch states when a profile is activated or deactivated * fix object mask default name when created from Explore tracking details * tweak annotation offset max in UI * optimize recordings/unavailable gap detection and drop empty motion activity buckets * add tests --- frigate/api/record.py | 26 +++++-- frigate/api/review.py | 5 ++ frigate/config/profile_manager.py | 50 +++++++++++++- frigate/test/http_api/test_http_media.py | 72 ++++++++++++++++++++ frigate/test/http_api/test_http_review.py | 11 ++- frigate/test/test_profiles.py | 31 +++++++++ web/src/lib/const.ts | 2 +- web/src/views/settings/MasksAndZonesView.tsx | 4 +- 8 files changed, 185 insertions(+), 16 deletions(-) diff --git a/frigate/api/record.py b/frigate/api/record.py index f6366813b6..04b81cf38a 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -299,22 +299,36 @@ async def no_recordings( .iterator() ) - # Convert recordings to list of (start, end) tuples + # Convert recordings to list of (start, end) tuples, ordered by start_time recordings = [(r["start_time"], r["end_time"]) for r in data] + # Merge overlapping/adjacent recordings into covered intervals. The query + # orders by start_time, so a single pass merges them + covered: list[tuple[float, float]] = [] + for rec_start, rec_end in recordings: + if covered and rec_start <= covered[-1][1]: + covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end)) + else: + covered.append((rec_start, rec_end)) + # Iterate through time segments and check if each has any recording no_recording_segments = [] current = after current_gap_start = None + idx = 0 + covered_count = len(covered) while current < before: segment_end = min(current + scale, before) - # Check if this segment overlaps with any recording - has_recording = any( - rec_start < segment_end and rec_end > current - for rec_start, rec_end in recordings - ) + # Advance past covered intervals that end before this segment begins; + # they cannot overlap this or any later segment. + while idx < covered_count and covered[idx][1] <= current: + idx += 1 + + # A covered interval overlaps the segment when it starts before the + # segment ends (its end is already known to be > current). + has_recording = idx < covered_count and covered[idx][0] < segment_end if not has_recording: # This segment has no recordings diff --git a/frigate/api/review.py b/frigate/api/review.py index cb114db2a0..bb4a9a70ee 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -658,6 +658,11 @@ def motion_activity( else: df.iloc[i : i + chunk, 0] = 0.0 + # Drop resample gap-fill buckets. The resample above emits a row for every + # {scale}s bucket spanning the range, and buckets with no recording get a + # motion of 0 (from fillna) and an empty camera (from joining an empty set). + df = df[df["camera"] != ""] + # change types for output df.index = df.index.astype(int) // (10**9) normalized = df.reset_index().to_dict("records") diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 6aba8f1942..e0a40ee353 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -5,7 +5,7 @@ import json import logging from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional from frigate.config.camera.updater import ( CameraConfigUpdateEnum, @@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "zones": CameraConfigUpdateEnum.zones, } +# Retained MQTT switch topics per profile section, with a payload getter. +# Republished on profile change so MQTT/HA don't show a stale toggle. +SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = { + "audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")], + "birdseye": [ + ("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"), + ( + "birdseye_mode", + lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF", + ), + ], + "detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")], + "motion": [ + ("motion", lambda c: "ON" if c.motion.enabled else "OFF"), + ("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"), + ("motion_threshold", lambda c: c.motion.threshold), + ("motion_contour_area", lambda c: c.motion.contour_area), + ], + "notifications": [ + ("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"), + ], + "objects": [ + ("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"), + ], + "record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")], + "review": [ + ("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"), + ( + "review_detections", + lambda c: "ON" if c.review.detections.enabled else "OFF", + ), + ( + "review_descriptions", + lambda c: "ON" if c.review.genai.enabled else "OFF", + ), + ], + "snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")], +} + PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles" @@ -310,6 +349,15 @@ class ProfileManager: settings, ) + # republish MQTT switch states + if self.dispatcher is not None: + for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()): + self.dispatcher.publish( + f"{cam_name}/{suffix}/state", + get_payload(cam_config), + retain=True, + ) + def _persist_active_profile(self, profile_name: Optional[str]) -> None: """Persist the active profile state to disk as JSON.""" try: diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 6af3dd9724..58f9f92477 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -403,3 +403,75 @@ class TestHttpMedia(BaseTestHttp): assert len(summary) == 1 assert "2024-03-10" in summary assert summary["2024-03-10"] is True + + def test_recordings_unavailable_reports_gap_between_recordings(self): + """A gap between two recordings is reported as an unavailable segment.""" + with AuthTestClient(self.app) as client: + # Two recordings with a 20s gap (1010-1030) between them. + Recordings.insert( + id="rec_a", + path="/media/recordings/a.mp4", + camera="front_door", + start_time=1000, + end_time=1010, + duration=10, + motion=0, + ).execute() + Recordings.insert( + id="rec_b", + path="/media/recordings/b.mp4", + camera="front_door", + start_time=1030, + end_time=1040, + duration=10, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1040, + "scale": 5, + "cameras": "front_door", + }, + ) + + assert response.status_code == 200 + assert response.json() == [{"start_time": 1010, "end_time": 1030}] + + def test_recordings_unavailable_merges_overlapping_recordings(self): + """Overlapping recordings are merged so no false gap is reported.""" + with AuthTestClient(self.app) as client: + # Overlapping recordings spanning the whole requested range. + Recordings.insert( + id="rec_a", + path="/media/recordings/a.mp4", + camera="front_door", + start_time=1000, + end_time=1020, + duration=20, + motion=0, + ).execute() + Recordings.insert( + id="rec_b", + path="/media/recordings/b.mp4", + camera="front_door", + start_time=1010, + end_time=1030, + duration=20, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1030, + "scale": 5, + "cameras": "front_door", + }, + ) + + assert response.status_code == 200 + assert response.json() == [] diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index ca73c87064..d13a7bd273 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp): response = client.get("/review/activity/motion", params=params) assert response.status_code == 200 response_json = response.json() - assert len(response_json) == 61 + # Only buckets with an actual recording are returned. Empty + # gap-fill buckets between the two recordings are dropped. + assert len(response_json) == 2 self.assertDictEqual( {"motion": 50.5, "camera": "front_door", "start_time": now + 1}, response_json[0], ) - for item in response_json[1:-1]: - self.assertDictEqual( - {"motion": 0.0, "camera": "", "start_time": item["start_time"]}, - item, - ) self.assertDictEqual( {"motion": 100.0, "camera": "front_door", "start_time": one_m + 1}, - response_json[len(response_json) - 1], + response_json[1], ) #################################################################################################################### diff --git a/frigate/test/test_profiles.py b/frigate/test/test_profiles.py index 6766b39163..59dc077466 100644 --- a/frigate/test/test_profiles.py +++ b/frigate/test/test_profiles.py @@ -1,5 +1,6 @@ """Tests for the profiles system.""" +import copy import json import os import unittest @@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase): manager.activate_profile(None) dispatcher.clear_runtime_state.assert_called_once_with() + @patch.object(ProfileManager, "_persist_active_profile") + def test_profile_change_republishes_switch_states(self, mock_persist): + """Profile changes republish MQTT switch states so HA stays in sync. + + Regression: activating/deactivating a profile updated the in-memory + config (and Frigate's behavior) but left the retained MQTT state + topics stale, so external integrations like Home Assistant kept + showing the pre-profile toggle position. + """ + config_data = copy.deepcopy(self.config_data) + config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = { + "alerts": {"enabled": False}, + } + config = FrigateConfig(**config_data) + dispatcher = MagicMock() + manager = ProfileManager(config, self.mock_updater, dispatcher) + + # Activating disarmed turns alerts off -> MQTT state must follow + manager.activate_profile("disarmed") + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "OFF", retain=True + ) + + # Deactivating restores the base (alerts on) -> MQTT state must follow + dispatcher.publish.reset_mock() + manager.activate_profile(None) + dispatcher.publish.assert_any_call( + "front/review_alerts/state", "ON", retain=True + ) + @patch.object(ProfileManager, "_persist_active_profile") def test_startup_replay_does_not_clear_runtime_state(self, mock_persist): """Startup callers pass clear_runtime_overrides=False to preserve state.""" diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index 2802870201..ff1d57a08f 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const; export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"; export const ANNOTATION_OFFSET_MIN = -10000; -export const ANNOTATION_OFFSET_MAX = 5000; +export const ANNOTATION_OFFSET_MAX = 10000; export const ANNOTATION_OFFSET_STEP = 50; export const supportedLanguageKeys = [ diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 5db190141f..7b803d62fb 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -53,6 +53,7 @@ export default function MasksAndZonesView({ const { data: config } = useSWR("config"); const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); + const [polygonsInitialized, setPolygonsInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loadingPolygonIndex, setLoadingPolygonIndex] = useState< number | undefined @@ -609,6 +610,7 @@ export default function MasksAndZonesView({ ...globalObjectMasks, ...objectMasks, ]); + setPolygonsInitialized(true); // Don't overwrite editingPolygons during editing – layout shifts // from switching to the edit pane can trigger a resize which // recalculates scaledWidth/scaledHeight and would discard the @@ -676,7 +678,7 @@ export default function MasksAndZonesView({ }, [currentEditingProfile]); useSearchEffect("object_mask", (coordinates: string) => { - if (!scaledWidth || !scaledHeight || isLoading) { + if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) { return false; } // convert box points string to points array