mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 23:15:28 +03:00
Compare commits
No commits in common. "e1245cb93d3fa7ccf89db34be68aaf0d09aaac1b" and "4695e10341ba779d8ed339c54be5e9109aedd090" have entirely different histories.
e1245cb93d
...
4695e10341
@ -142,20 +142,9 @@ def config(request: Request):
|
|||||||
# remove the proxy secret
|
# remove the proxy secret
|
||||||
config["proxy"].pop("auth_secret", None)
|
config["proxy"].pop("auth_secret", None)
|
||||||
|
|
||||||
# remove genai api keys
|
|
||||||
for genai_name, genai_cfg in config.get("genai", {}).items():
|
|
||||||
if isinstance(genai_cfg, dict):
|
|
||||||
genai_cfg.pop("api_key", None)
|
|
||||||
|
|
||||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||||
camera_dict = config["cameras"][camera_name]
|
camera_dict = config["cameras"][camera_name]
|
||||||
|
|
||||||
# remove onvif credentials
|
|
||||||
onvif_dict = camera_dict.get("onvif", {})
|
|
||||||
if onvif_dict:
|
|
||||||
onvif_dict.pop("user", None)
|
|
||||||
onvif_dict.pop("password", None)
|
|
||||||
|
|
||||||
# clean paths
|
# clean paths
|
||||||
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
||||||
input["path"] = clean_camera_user_pass(input["path"])
|
input["path"] = clean_camera_user_pass(input["path"])
|
||||||
|
|||||||
@ -15,7 +15,6 @@ from pydantic import BaseModel
|
|||||||
from frigate.api.auth import (
|
from frigate.api.auth import (
|
||||||
allow_any_authenticated,
|
allow_any_authenticated,
|
||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
require_camera_access,
|
|
||||||
)
|
)
|
||||||
from frigate.api.defs.query.events_query_parameters import EventsQueryParams
|
from frigate.api.defs.query.events_query_parameters import EventsQueryParams
|
||||||
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
||||||
@ -294,60 +293,6 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_profile_status",
|
|
||||||
"description": (
|
|
||||||
"Get the current profile status including the active profile and "
|
|
||||||
"timestamps of when each profile was last activated. Use this to "
|
|
||||||
"determine time periods for recap requests — e.g. when the user asks "
|
|
||||||
"'what happened while I was away?', call this first to find the relevant "
|
|
||||||
"time window based on profile activation history."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_recap",
|
|
||||||
"description": (
|
|
||||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
|
||||||
"Use this after calling get_profile_status to retrieve what happened during "
|
|
||||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
|
||||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
|
||||||
"descriptions when available. Summarize the results for the user."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"after": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
|
||||||
},
|
|
||||||
"before": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
|
|
||||||
},
|
|
||||||
"cameras": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
|
|
||||||
},
|
|
||||||
"severity": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["alert", "detection"],
|
|
||||||
"description": "Filter by severity level. Omit to include both alerts and detections.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["after", "before"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -700,14 +645,10 @@ async def _execute_tool_internal(
|
|||||||
return await _execute_start_camera_watch(request, arguments)
|
return await _execute_start_camera_watch(request, arguments)
|
||||||
elif tool_name == "stop_camera_watch":
|
elif tool_name == "stop_camera_watch":
|
||||||
return _execute_stop_camera_watch()
|
return _execute_stop_camera_watch()
|
||||||
elif tool_name == "get_profile_status":
|
|
||||||
return _execute_get_profile_status(request)
|
|
||||||
elif tool_name == "get_recap":
|
|
||||||
return _execute_get_recap(arguments, allowed_cameras)
|
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
|
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
|
||||||
"start_camera_watch, stop_camera_watch, get_profile_status, get_recap. Arguments received: %s",
|
"start_camera_watch, stop_camera_watch. Arguments received: %s",
|
||||||
tool_name,
|
tool_name,
|
||||||
json.dumps(arguments),
|
json.dumps(arguments),
|
||||||
)
|
)
|
||||||
@ -731,8 +672,6 @@ async def _execute_start_camera_watch(
|
|||||||
if camera not in config.cameras:
|
if camera not in config.cameras:
|
||||||
return {"error": f"Camera '{camera}' not found."}
|
return {"error": f"Camera '{camera}' not found."}
|
||||||
|
|
||||||
await require_camera_access(camera, request=request)
|
|
||||||
|
|
||||||
genai_manager = request.app.genai_manager
|
genai_manager = request.app.genai_manager
|
||||||
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
||||||
if vision_client is None:
|
if vision_client is None:
|
||||||
@ -771,168 +710,6 @@ def _execute_stop_camera_watch() -> Dict[str, Any]:
|
|||||||
return {"success": False, "message": "No active watch job to cancel."}
|
return {"success": False, "message": "No active watch job to cancel."}
|
||||||
|
|
||||||
|
|
||||||
def _execute_get_profile_status(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Return profile status including active profile and activation timestamps."""
|
|
||||||
profile_manager = getattr(request.app, "profile_manager", None)
|
|
||||||
if profile_manager is None:
|
|
||||||
return {"error": "Profile manager is not available."}
|
|
||||||
|
|
||||||
info = profile_manager.get_profile_info()
|
|
||||||
|
|
||||||
# Convert timestamps to human-readable local times inline
|
|
||||||
last_activated = {}
|
|
||||||
for name, ts in info.get("last_activated", {}).items():
|
|
||||||
try:
|
|
||||||
dt = datetime.fromtimestamp(ts)
|
|
||||||
last_activated[name] = dt.strftime("%Y-%m-%d %I:%M:%S %p")
|
|
||||||
except (TypeError, ValueError, OSError):
|
|
||||||
last_activated[name] = str(ts)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"active_profile": info.get("active_profile"),
|
|
||||||
"profiles": info.get("profiles", []),
|
|
||||||
"last_activated": last_activated,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_get_recap(
|
|
||||||
arguments: Dict[str, Any],
|
|
||||||
allowed_cameras: List[str],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetch review segments with GenAI metadata for a time period."""
|
|
||||||
from functools import reduce
|
|
||||||
|
|
||||||
from peewee import operator
|
|
||||||
|
|
||||||
from frigate.models import ReviewSegment
|
|
||||||
|
|
||||||
after_str = arguments.get("after")
|
|
||||||
before_str = arguments.get("before")
|
|
||||||
|
|
||||||
def _parse_as_local_timestamp(s: str):
|
|
||||||
s = s.replace("Z", "").strip()[:19]
|
|
||||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
|
||||||
return time.mktime(dt.timetuple())
|
|
||||||
|
|
||||||
try:
|
|
||||||
after = _parse_as_local_timestamp(after_str)
|
|
||||||
except (ValueError, AttributeError, TypeError):
|
|
||||||
return {"error": f"Invalid 'after' timestamp: {after_str}"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
before = _parse_as_local_timestamp(before_str)
|
|
||||||
except (ValueError, AttributeError, TypeError):
|
|
||||||
return {"error": f"Invalid 'before' timestamp: {before_str}"}
|
|
||||||
|
|
||||||
cameras = arguments.get("cameras", "all")
|
|
||||||
if cameras != "all":
|
|
||||||
requested = set(cameras.split(","))
|
|
||||||
camera_list = list(requested.intersection(allowed_cameras))
|
|
||||||
if not camera_list:
|
|
||||||
return {"events": [], "message": "No accessible cameras matched."}
|
|
||||||
else:
|
|
||||||
camera_list = allowed_cameras
|
|
||||||
|
|
||||||
clauses = [
|
|
||||||
(ReviewSegment.start_time < before)
|
|
||||||
& ((ReviewSegment.end_time.is_null(True)) | (ReviewSegment.end_time > after)),
|
|
||||||
(ReviewSegment.camera << camera_list),
|
|
||||||
]
|
|
||||||
|
|
||||||
severity_filter = arguments.get("severity")
|
|
||||||
if severity_filter:
|
|
||||||
clauses.append(ReviewSegment.severity == severity_filter)
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = (
|
|
||||||
ReviewSegment.select(
|
|
||||||
ReviewSegment.camera,
|
|
||||||
ReviewSegment.start_time,
|
|
||||||
ReviewSegment.end_time,
|
|
||||||
ReviewSegment.severity,
|
|
||||||
ReviewSegment.data,
|
|
||||||
)
|
|
||||||
.where(reduce(operator.and_, clauses))
|
|
||||||
.order_by(ReviewSegment.start_time.asc())
|
|
||||||
.limit(100)
|
|
||||||
.dicts()
|
|
||||||
.iterator()
|
|
||||||
)
|
|
||||||
|
|
||||||
events: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
data = row.get("data") or {}
|
|
||||||
if isinstance(data, str):
|
|
||||||
try:
|
|
||||||
data = json.loads(data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
camera = row["camera"]
|
|
||||||
event: Dict[str, Any] = {
|
|
||||||
"camera": camera.replace("_", " ").title(),
|
|
||||||
"severity": row.get("severity", "detection"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Include GenAI metadata when available
|
|
||||||
metadata = data.get("metadata")
|
|
||||||
if metadata and isinstance(metadata, dict):
|
|
||||||
if metadata.get("title"):
|
|
||||||
event["title"] = metadata["title"]
|
|
||||||
if metadata.get("scene"):
|
|
||||||
event["description"] = metadata["scene"]
|
|
||||||
threat = metadata.get("potential_threat_level")
|
|
||||||
if threat is not None:
|
|
||||||
threat_labels = {
|
|
||||||
0: "normal",
|
|
||||||
1: "needs_review",
|
|
||||||
2: "security_concern",
|
|
||||||
}
|
|
||||||
event["threat_level"] = threat_labels.get(threat, str(threat))
|
|
||||||
|
|
||||||
# Only include objects/zones/audio when there's no GenAI description
|
|
||||||
# to keep the payload concise — the description already covers these
|
|
||||||
if "description" not in event:
|
|
||||||
objects = data.get("objects", [])
|
|
||||||
if objects:
|
|
||||||
event["objects"] = objects
|
|
||||||
zones = data.get("zones", [])
|
|
||||||
if zones:
|
|
||||||
event["zones"] = zones
|
|
||||||
audio = data.get("audio", [])
|
|
||||||
if audio:
|
|
||||||
event["audio"] = audio
|
|
||||||
|
|
||||||
start_ts = row.get("start_time")
|
|
||||||
end_ts = row.get("end_time")
|
|
||||||
if start_ts is not None:
|
|
||||||
try:
|
|
||||||
event["time"] = datetime.fromtimestamp(start_ts).strftime(
|
|
||||||
"%I:%M %p"
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, OSError):
|
|
||||||
pass
|
|
||||||
if end_ts is not None and start_ts is not None:
|
|
||||||
try:
|
|
||||||
event["duration_seconds"] = round(end_ts - start_ts)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
events.append(event)
|
|
||||||
|
|
||||||
if not events:
|
|
||||||
return {
|
|
||||||
"events": [],
|
|
||||||
"message": "No activity was found during this time period.",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"events": events}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error executing get_recap: %s", e, exc_info=True)
|
|
||||||
return {"error": "Failed to fetch recap data."}
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute_pending_tools(
|
async def _execute_pending_tools(
|
||||||
pending_tool_calls: List[Dict[str, Any]],
|
pending_tool_calls: List[Dict[str, Any]],
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -1379,8 +1156,6 @@ async def start_vlm_monitor(
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
await require_camera_access(body.camera, request=request)
|
|
||||||
|
|
||||||
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
vision_client = genai_manager.vision_client or genai_manager.tool_client
|
||||||
if vision_client is None:
|
if vision_client is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@ -896,7 +896,6 @@ async def event_thumbnail(
|
|||||||
if event_id in camera_state.tracked_objects:
|
if event_id in camera_state.tracked_objects:
|
||||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||||
if tracked_obj is not None:
|
if tracked_obj is not None:
|
||||||
await require_camera_access(camera_state.name, request=request)
|
|
||||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -1067,7 +1066,7 @@ def grid_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{camera_name}/region_grid", dependencies=[Depends(require_role(["admin"]))]
|
"/{camera_name}/region_grid", dependencies=[Depends(require_role("admin"))]
|
||||||
)
|
)
|
||||||
def clear_region_grid(request: Request, camera_name: str):
|
def clear_region_grid(request: Request, camera_name: str):
|
||||||
"""Clear the region grid for a camera."""
|
"""Clear the region grid for a camera."""
|
||||||
|
|||||||
@ -742,7 +742,7 @@ async def set_not_reviewed(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/review/summarize/start/{start_ts}/end/{end_ts}",
|
"/review/summarize/start/{start_ts}/end/{end_ts}",
|
||||||
dependencies=[Depends(require_role(["admin"]))],
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
description="Use GenAI to summarize review items over a period of time.",
|
description="Use GenAI to summarize review items over a period of time.",
|
||||||
)
|
)
|
||||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
"""Profile manager for activating/deactivating named config profiles."""
|
"""Profile manager for activating/deactivating named config profiles."""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -34,7 +32,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
|
|||||||
"zones": CameraConfigUpdateEnum.zones,
|
"zones": CameraConfigUpdateEnum.zones,
|
||||||
}
|
}
|
||||||
|
|
||||||
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
|
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile"
|
||||||
|
|
||||||
|
|
||||||
class ProfileManager:
|
class ProfileManager:
|
||||||
@ -293,36 +291,25 @@ class ProfileManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 name to disk."""
|
||||||
try:
|
try:
|
||||||
data = self._load_persisted_data()
|
if profile_name is None:
|
||||||
data["active"] = profile_name
|
PERSISTENCE_FILE.unlink(missing_ok=True)
|
||||||
if profile_name is not None:
|
else:
|
||||||
data.setdefault("last_activated", {})[profile_name] = datetime.now(
|
PERSISTENCE_FILE.write_text(profile_name)
|
||||||
timezone.utc
|
|
||||||
).timestamp()
|
|
||||||
PERSISTENCE_FILE.write_text(json.dumps(data))
|
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.exception("Failed to persist active profile")
|
logger.exception("Failed to persist active profile")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _load_persisted_data() -> dict:
|
|
||||||
"""Load the full persisted profile data from disk."""
|
|
||||||
try:
|
|
||||||
if PERSISTENCE_FILE.exists():
|
|
||||||
raw = PERSISTENCE_FILE.read_text().strip()
|
|
||||||
if raw:
|
|
||||||
return json.loads(raw)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
logger.exception("Failed to load persisted profile data")
|
|
||||||
return {"active": None, "last_activated": {}}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_persisted_profile() -> Optional[str]:
|
def load_persisted_profile() -> Optional[str]:
|
||||||
"""Load the persisted active profile name from disk."""
|
"""Load the persisted active profile name from disk."""
|
||||||
data = ProfileManager._load_persisted_data()
|
try:
|
||||||
name = data.get("active")
|
if PERSISTENCE_FILE.exists():
|
||||||
return name if name else None
|
name = PERSISTENCE_FILE.read_text().strip()
|
||||||
|
return name if name else None
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Failed to load persisted profile")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]:
|
def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]:
|
||||||
"""Return base (pre-profile) section configs for a camera.
|
"""Return base (pre-profile) section configs for a camera.
|
||||||
@ -341,9 +328,7 @@ class ProfileManager:
|
|||||||
|
|
||||||
def get_profile_info(self) -> dict:
|
def get_profile_info(self) -> dict:
|
||||||
"""Get profile state info for API responses."""
|
"""Get profile state info for API responses."""
|
||||||
data = self._load_persisted_data()
|
|
||||||
return {
|
return {
|
||||||
"profiles": self.get_available_profiles(),
|
"profiles": self.get_available_profiles(),
|
||||||
"active_profile": self.config.active_profile,
|
"active_profile": self.config.active_profile,
|
||||||
"last_activated": data.get("last_activated", {}),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,9 +36,7 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||||
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||||
|
|
||||||
# ffmpeg flags that can read from or write to arbitrary files.
|
# ffmpeg flags that can read from or write to arbitrary files
|
||||||
# filter flags are blocked because source filters like movie= and
|
|
||||||
# amovie= can read arbitrary files from the filesystem.
|
|
||||||
BLOCKED_FFMPEG_ARGS = frozenset(
|
BLOCKED_FFMPEG_ARGS = frozenset(
|
||||||
{
|
{
|
||||||
"-i",
|
"-i",
|
||||||
@ -47,12 +45,6 @@ BLOCKED_FFMPEG_ARGS = frozenset(
|
|||||||
"-passlogfile",
|
"-passlogfile",
|
||||||
"-sdp_file",
|
"-sdp_file",
|
||||||
"-dump_attachment",
|
"-dump_attachment",
|
||||||
"-filter_complex",
|
|
||||||
"-lavfi",
|
|
||||||
"-vf",
|
|
||||||
"-af",
|
|
||||||
"-filter",
|
|
||||||
"-attach",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Tests for the profiles system."""
|
"""Tests for the profiles system."""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@ -549,17 +549,10 @@ class TestProfileManager(unittest.TestCase):
|
|||||||
|
|
||||||
def test_get_profile_info(self):
|
def test_get_profile_info(self):
|
||||||
"""Profile info returns correct structure with friendly names."""
|
"""Profile info returns correct structure with friendly names."""
|
||||||
with patch.object(
|
info = self.manager.get_profile_info()
|
||||||
ProfileManager,
|
|
||||||
"_load_persisted_data",
|
|
||||||
return_value={"active": None, "last_activated": {}},
|
|
||||||
):
|
|
||||||
info = self.manager.get_profile_info()
|
|
||||||
assert "profiles" in info
|
assert "profiles" in info
|
||||||
assert "active_profile" in info
|
assert "active_profile" in info
|
||||||
assert "last_activated" in info
|
|
||||||
assert info["active_profile"] is None
|
assert info["active_profile"] is None
|
||||||
assert info["last_activated"] == {}
|
|
||||||
names = [p["name"] for p in info["profiles"]]
|
names = [p["name"] for p in info["profiles"]]
|
||||||
assert "armed" in names
|
assert "armed" in names
|
||||||
assert "disarmed" in names
|
assert "disarmed" in names
|
||||||
@ -597,22 +590,33 @@ class TestProfilePersistence(unittest.TestCase):
|
|||||||
"""Test profile persistence to disk."""
|
"""Test profile persistence to disk."""
|
||||||
|
|
||||||
def test_persist_and_load(self):
|
def test_persist_and_load(self):
|
||||||
"""Active profile name can be persisted and loaded via JSON."""
|
"""Active profile name can be persisted and loaded."""
|
||||||
data = {"active": "armed", "last_activated": {"armed": 1700000000.0}}
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
with patch.object(
|
temp_path = f.name
|
||||||
ProfileManager,
|
|
||||||
"_load_persisted_data",
|
try:
|
||||||
return_value=data,
|
from pathlib import Path
|
||||||
):
|
|
||||||
result = ProfileManager.load_persisted_profile()
|
path = Path(temp_path)
|
||||||
assert result == "armed"
|
path.write_text("armed")
|
||||||
|
loaded = path.read_text().strip()
|
||||||
|
assert loaded == "armed"
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
def test_load_empty_file(self):
|
def test_load_empty_file(self):
|
||||||
"""Empty persistence file returns None."""
|
"""Empty persistence file returns None."""
|
||||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True):
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""):
|
f.write("")
|
||||||
result = ProfileManager.load_persisted_profile()
|
temp_path = f.name
|
||||||
assert result is None
|
|
||||||
|
try:
|
||||||
|
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True):
|
||||||
|
with patch.object(type(PERSISTENCE_FILE), "read_text", return_value=""):
|
||||||
|
result = ProfileManager.load_persisted_profile()
|
||||||
|
assert result is None
|
||||||
|
finally:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
|
||||||
def test_load_missing_file(self):
|
def test_load_missing_file(self):
|
||||||
"""Missing persistence file returns None."""
|
"""Missing persistence file returns None."""
|
||||||
@ -620,118 +624,6 @@ class TestProfilePersistence(unittest.TestCase):
|
|||||||
result = ProfileManager.load_persisted_profile()
|
result = ProfileManager.load_persisted_profile()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_load_persisted_data_valid_json(self):
|
|
||||||
"""Valid JSON file is loaded correctly."""
|
|
||||||
data = {"active": "home", "last_activated": {"home": 1700000000.0}}
|
|
||||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True):
|
|
||||||
with patch.object(
|
|
||||||
type(PERSISTENCE_FILE),
|
|
||||||
"read_text",
|
|
||||||
return_value=json.dumps(data),
|
|
||||||
):
|
|
||||||
result = ProfileManager._load_persisted_data()
|
|
||||||
assert result == data
|
|
||||||
|
|
||||||
def test_load_persisted_data_invalid_json(self):
|
|
||||||
"""Invalid JSON returns default structure."""
|
|
||||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=True):
|
|
||||||
with patch.object(
|
|
||||||
type(PERSISTENCE_FILE), "read_text", return_value="not json"
|
|
||||||
):
|
|
||||||
result = ProfileManager._load_persisted_data()
|
|
||||||
assert result == {"active": None, "last_activated": {}}
|
|
||||||
|
|
||||||
def test_load_persisted_data_missing_file(self):
|
|
||||||
"""Missing file returns default structure."""
|
|
||||||
with patch.object(type(PERSISTENCE_FILE), "exists", return_value=False):
|
|
||||||
result = ProfileManager._load_persisted_data()
|
|
||||||
assert result == {"active": None, "last_activated": {}}
|
|
||||||
|
|
||||||
def test_persist_records_timestamp(self):
|
|
||||||
"""Persisting a profile records the activation timestamp."""
|
|
||||||
config_data = {
|
|
||||||
"mqtt": {"host": "mqtt"},
|
|
||||||
"profiles": {"armed": {"friendly_name": "Armed"}},
|
|
||||||
"cameras": {
|
|
||||||
"front": {
|
|
||||||
"ffmpeg": {
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"path": "rtsp://10.0.0.1:554/video",
|
|
||||||
"roles": ["detect"],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
|
||||||
"profiles": {"armed": {"detect": {"enabled": True}}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
|
|
||||||
os.makedirs(MODEL_CACHE_DIR)
|
|
||||||
config = FrigateConfig(**config_data)
|
|
||||||
manager = ProfileManager(config, MagicMock())
|
|
||||||
|
|
||||||
written_data = {}
|
|
||||||
|
|
||||||
def mock_write(_self, content):
|
|
||||||
written_data.update(json.loads(content))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
ProfileManager,
|
|
||||||
"_load_persisted_data",
|
|
||||||
return_value={"active": None, "last_activated": {}},
|
|
||||||
):
|
|
||||||
with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write):
|
|
||||||
manager._persist_active_profile("armed")
|
|
||||||
|
|
||||||
assert written_data["active"] == "armed"
|
|
||||||
assert "armed" in written_data["last_activated"]
|
|
||||||
assert isinstance(written_data["last_activated"]["armed"], float)
|
|
||||||
|
|
||||||
def test_persist_deactivate_keeps_timestamps(self):
|
|
||||||
"""Deactivating sets active to None but preserves last_activated."""
|
|
||||||
existing = {
|
|
||||||
"active": "armed",
|
|
||||||
"last_activated": {"armed": 1700000000.0},
|
|
||||||
}
|
|
||||||
written_data = {}
|
|
||||||
|
|
||||||
def mock_write(_self, content):
|
|
||||||
written_data.update(json.loads(content))
|
|
||||||
|
|
||||||
config_data = {
|
|
||||||
"mqtt": {"host": "mqtt"},
|
|
||||||
"profiles": {"armed": {"friendly_name": "Armed"}},
|
|
||||||
"cameras": {
|
|
||||||
"front": {
|
|
||||||
"ffmpeg": {
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"path": "rtsp://10.0.0.1:554/video",
|
|
||||||
"roles": ["detect"],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
|
||||||
"profiles": {"armed": {"detect": {"enabled": True}}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if not os.path.exists(MODEL_CACHE_DIR) and not os.path.islink(MODEL_CACHE_DIR):
|
|
||||||
os.makedirs(MODEL_CACHE_DIR)
|
|
||||||
config = FrigateConfig(**config_data)
|
|
||||||
manager = ProfileManager(config, MagicMock())
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
ProfileManager, "_load_persisted_data", return_value=existing
|
|
||||||
):
|
|
||||||
with patch.object(type(PERSISTENCE_FILE), "write_text", mock_write):
|
|
||||||
manager._persist_active_profile(None)
|
|
||||||
|
|
||||||
assert written_data["active"] is None
|
|
||||||
assert written_data["last_activated"]["armed"] == 1700000000.0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@ -15,14 +15,10 @@
|
|||||||
"suggested_requests": "Try asking:",
|
"suggested_requests": "Try asking:",
|
||||||
"starting_requests": {
|
"starting_requests": {
|
||||||
"show_recent_events": "Show recent events",
|
"show_recent_events": "Show recent events",
|
||||||
"show_camera_status": "Show camera status",
|
"show_camera_status": "Show camera status"
|
||||||
"recap": "What happened while I was away?",
|
|
||||||
"watch_camera": "Watch a camera for activity"
|
|
||||||
},
|
},
|
||||||
"starting_requests_prompts": {
|
"starting_requests_prompts": {
|
||||||
"show_recent_events": "Show me the recent events from the last hour",
|
"show_recent_events": "Show me the recent events from the last hour",
|
||||||
"show_camera_status": "What is the current status of my cameras?",
|
"show_camera_status": "What is the current status of my cameras?"
|
||||||
"recap": "What happened while I was away?",
|
|
||||||
"watch_camera": "Watch the front door and let me know if anyone shows up"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,14 +22,6 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
|
|||||||
label: t("starting_requests.show_camera_status"),
|
label: t("starting_requests.show_camera_status"),
|
||||||
prompt: t("starting_requests_prompts.show_camera_status"),
|
prompt: t("starting_requests_prompts.show_camera_status"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("starting_requests.recap"),
|
|
||||||
prompt: t("starting_requests_prompts.recap"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("starting_requests.watch_camera"),
|
|
||||||
prompt: t("starting_requests_prompts.watch_camera"),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRequestClick = (prompt: string) => {
|
const handleRequestClick = (prompt: string) => {
|
||||||
|
|||||||
@ -14,7 +14,6 @@ export type ProfileInfo = {
|
|||||||
export type ProfilesApiResponse = {
|
export type ProfilesApiResponse = {
|
||||||
profiles: ProfileInfo[];
|
profiles: ProfileInfo[];
|
||||||
active_profile: string | null;
|
active_profile: string | null;
|
||||||
last_activated: Record<string, number>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProfileState = {
|
export type ProfileState = {
|
||||||
|
|||||||
@ -649,11 +649,8 @@ const mergeSectionConfig = (
|
|||||||
return srcValue ?? objValue;
|
return srcValue ?? objValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "uiSchema") {
|
if (key === "uiSchema" && srcValue !== undefined) {
|
||||||
if (objValue && srcValue) {
|
return srcValue;
|
||||||
return merge({}, objValue, srcValue);
|
|
||||||
}
|
|
||||||
return srcValue ?? objValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user