mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
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:
parent
8073174c20
commit
570e21340a
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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() == []
|
||||
|
||||
@ -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],
|
||||
)
|
||||
|
||||
####################################################################################################################
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -53,6 +53,7 @@ export default function MasksAndZonesView({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user