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
This commit is contained in:
Josh Hawkins 2026-06-01 14:55:52 -05:00 committed by GitHub
parent 8073174c20
commit 570e21340a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 185 additions and 16 deletions

View File

@ -299,22 +299,36 @@ async def no_recordings(
.iterator() .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] 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 # Iterate through time segments and check if each has any recording
no_recording_segments = [] no_recording_segments = []
current = after current = after
current_gap_start = None current_gap_start = None
idx = 0
covered_count = len(covered)
while current < before: while current < before:
segment_end = min(current + scale, before) segment_end = min(current + scale, before)
# Check if this segment overlaps with any recording # Advance past covered intervals that end before this segment begins;
has_recording = any( # they cannot overlap this or any later segment.
rec_start < segment_end and rec_end > current while idx < covered_count and covered[idx][1] <= current:
for rec_start, rec_end in recordings 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: if not has_recording:
# This segment has no recordings # This segment has no recordings

View File

@ -658,6 +658,11 @@ def motion_activity(
else: else:
df.iloc[i : i + chunk, 0] = 0.0 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 # change types for output
df.index = df.index.astype(int) // (10**9) df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records") normalized = df.reset_index().to_dict("records")

View File

@ -5,7 +5,7 @@ import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Callable, Optional
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
@ -34,6 +34,45 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
"zones": CameraConfigUpdateEnum.zones, "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" PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
@ -310,6 +349,15 @@ class ProfileManager:
settings, 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: def _persist_active_profile(self, profile_name: Optional[str]) -> None:
"""Persist the active profile state to disk as JSON.""" """Persist the active profile state to disk as JSON."""
try: try:

View File

@ -403,3 +403,75 @@ class TestHttpMedia(BaseTestHttp):
assert len(summary) == 1 assert len(summary) == 1
assert "2024-03-10" in summary assert "2024-03-10" in summary
assert summary["2024-03-10"] is True 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() == []

View File

@ -610,19 +610,16 @@ class TestHttpReview(BaseTestHttp):
response = client.get("/review/activity/motion", params=params) response = client.get("/review/activity/motion", params=params)
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() 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( self.assertDictEqual(
{"motion": 50.5, "camera": "front_door", "start_time": now + 1}, {"motion": 50.5, "camera": "front_door", "start_time": now + 1},
response_json[0], response_json[0],
) )
for item in response_json[1:-1]:
self.assertDictEqual(
{"motion": 0.0, "camera": "", "start_time": item["start_time"]},
item,
)
self.assertDictEqual( self.assertDictEqual(
{"motion": 100.0, "camera": "front_door", "start_time": one_m + 1}, {"motion": 100.0, "camera": "front_door", "start_time": one_m + 1},
response_json[len(response_json) - 1], response_json[1],
) )
#################################################################################################################### ####################################################################################################################

View File

@ -1,5 +1,6 @@
"""Tests for the profiles system.""" """Tests for the profiles system."""
import copy
import json import json
import os import os
import unittest import unittest
@ -746,6 +747,36 @@ class TestProfileManager(unittest.TestCase):
manager.activate_profile(None) manager.activate_profile(None)
dispatcher.clear_runtime_state.assert_called_once_with() 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") @patch.object(ProfileManager, "_persist_active_profile")
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist): def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
"""Startup callers pass clear_runtime_overrides=False to preserve state.""" """Startup callers pass clear_runtime_overrides=False to preserve state."""

View File

@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"; export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
export const ANNOTATION_OFFSET_MIN = -10000; 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 ANNOTATION_OFFSET_STEP = 50;
export const supportedLanguageKeys = [ export const supportedLanguageKeys = [

View File

@ -53,6 +53,7 @@ export default function MasksAndZonesView({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]); const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [polygonsInitialized, setPolygonsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState< const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
number | undefined number | undefined
@ -609,6 +610,7 @@ export default function MasksAndZonesView({
...globalObjectMasks, ...globalObjectMasks,
...objectMasks, ...objectMasks,
]); ]);
setPolygonsInitialized(true);
// Don't overwrite editingPolygons during editing layout shifts // Don't overwrite editingPolygons during editing layout shifts
// from switching to the edit pane can trigger a resize which // from switching to the edit pane can trigger a resize which
// recalculates scaledWidth/scaledHeight and would discard the // recalculates scaledWidth/scaledHeight and would discard the
@ -676,7 +678,7 @@ export default function MasksAndZonesView({
}, [currentEditingProfile]); }, [currentEditingProfile]);
useSearchEffect("object_mask", (coordinates: string) => { useSearchEffect("object_mask", (coordinates: string) => {
if (!scaledWidth || !scaledHeight || isLoading) { if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) {
return false; return false;
} }
// convert box points string to points array // convert box points string to points array