From cedcbdba078cb32305ce5553254ce09565b6f3ca Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 19 Mar 2026 16:39:28 -0600 Subject: [PATCH] Add ability to toggle camera features via API (#22538) * Refactor profile to be a generic state setter API * Add tool to chat * Cleanup * Cleanup --- frigate/api/app.py | 20 ---- frigate/api/camera.py | 74 ++++++++++++++ frigate/api/chat.py | 105 ++++++++++++++++++++ frigate/api/defs/request/app_body.py | 6 +- web/src/components/menu/GeneralSettings.tsx | 2 +- web/src/views/settings/ProfilesView.tsx | 6 +- 6 files changed, 185 insertions(+), 28 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index cb408b447..6df0b1883 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -34,7 +34,6 @@ from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryPa from frigate.api.defs.request.app_body import ( AppConfigSetBody, MediaSyncBody, - ProfileSetBody, ) from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig @@ -244,25 +243,6 @@ def get_active_profile(request: Request): return JSONResponse(content={"active_profile": config_obj.active_profile}) -@router.put("/profile/set", dependencies=[Depends(require_role(["admin"]))]) -def set_profile(request: Request, body: ProfileSetBody): - """Activate or deactivate a profile.""" - profile_manager = request.app.profile_manager - err = profile_manager.activate_profile(body.profile) - if err: - return JSONResponse( - content={"success": False, "message": err}, - status_code=400, - ) - request.app.dispatcher.publish("profile/state", body.profile or "none", retain=True) - return JSONResponse( - content={ - "success": True, - "active_profile": body.profile, - } - ) - - @router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())]) def ffmpeg_presets(): """Return available ffmpeg preset keys for config UI usage.""" diff --git a/frigate/api/camera.py b/frigate/api/camera.py index cb69a56e3..c99126e64 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -23,6 +23,7 @@ from frigate.api.auth import ( require_camera_access, require_role, ) +from frigate.api.defs.request.app_body import CameraSetBody from frigate.api.defs.tags import Tags from frigate.config import FrigateConfig from frigate.config.camera.updater import ( @@ -1155,3 +1156,76 @@ async def delete_camera( }, status_code=200, ) + + +_SUB_COMMAND_FEATURES = {"motion_mask", "object_mask", "zone"} + + +@router.put( + "/camera/{camera_name}/set/{feature}", + dependencies=[Depends(require_role(["admin"]))], +) +@router.put( + "/camera/{camera_name}/set/{feature}/{sub_command}", + dependencies=[Depends(require_role(["admin"]))], +) +def camera_set( + request: Request, + camera_name: str, + feature: str, + body: CameraSetBody, + sub_command: str | None = None, +): + """Set a camera feature state. Use camera_name='*' to target all cameras.""" + dispatcher = request.app.dispatcher + frigate_config: FrigateConfig = request.app.frigate_config + + if feature == "profile": + if camera_name != "*": + return JSONResponse( + content={ + "success": False, + "message": "Profile feature requires camera_name='*'", + }, + status_code=400, + ) + dispatcher._receive("profile/set", body.value) + return JSONResponse(content={"success": True}) + + if feature not in dispatcher._camera_settings_handlers: + return JSONResponse( + content={"success": False, "message": f"Unknown feature: {feature}"}, + status_code=400, + ) + + if sub_command and feature not in _SUB_COMMAND_FEATURES: + return JSONResponse( + content={ + "success": False, + "message": f"Feature '{feature}' does not support sub-commands", + }, + status_code=400, + ) + + if camera_name == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera_name not in frigate_config.cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Camera '{camera_name}' not found", + }, + status_code=404, + ) + else: + cameras = [camera_name] + + for cam in cameras: + topic = ( + f"{cam}/{feature}/{sub_command}/set" + if sub_command + else f"{cam}/{feature}/set" + ) + dispatcher._receive(topic, body.value) + + return JSONResponse(content={"success": True}) diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 7957ab7af..b4eee6a9d 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -140,6 +140,62 @@ def get_tool_definitions() -> List[Dict[str, Any]]: "required": [], }, }, + { + "type": "function", + "function": { + "name": "set_camera_state", + "description": ( + "Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). " + "Use camera='*' to apply to all cameras at once. " + "Only call this tool when the user explicitly asks to change a camera setting. " + "Requires admin privileges." + ), + "parameters": { + "type": "object", + "properties": { + "camera": { + "type": "string", + "description": "Camera name to target, or '*' to target all cameras.", + }, + "feature": { + "type": "string", + "enum": [ + "detect", + "record", + "snapshots", + "audio", + "motion", + "enabled", + "birdseye", + "birdseye_mode", + "improve_contrast", + "ptz_autotracker", + "motion_contour_area", + "motion_threshold", + "notifications", + "audio_transcription", + "review_alerts", + "review_detections", + "object_descriptions", + "review_descriptions", + "profile", + ], + "description": ( + "The feature to change. Most features accept ON or OFF. " + "birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. " + "motion_contour_area and motion_threshold accept a number. " + "profile accepts a profile name or 'none' to deactivate (requires camera='*')." + ), + }, + "value": { + "type": "string", + "description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.", + }, + }, + "required": ["camera", "feature", "value"], + }, + }, + }, { "type": "function", "function": { @@ -255,6 +311,7 @@ async def _execute_search_objects( description="Execute a tool function call from an LLM.", ) async def execute_tool( + request: Request, body: ToolExecuteRequest = Body(...), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), ) -> JSONResponse: @@ -272,6 +329,12 @@ async def execute_tool( if tool_name == "search_objects": return await _execute_search_objects(arguments, allowed_cameras) + if tool_name == "set_camera_state": + result = await _execute_set_camera_state(request, arguments) + return JSONResponse( + content=result, status_code=200 if result.get("success") else 400 + ) + return JSONResponse( content={ "success": False, @@ -374,6 +437,46 @@ async def _get_live_frame_image_url( return None +async def _execute_set_camera_state( + request: Request, + arguments: Dict[str, Any], +) -> Dict[str, Any]: + role = request.headers.get("remote-role", "") + if "admin" not in [r.strip() for r in role.split(",")]: + return {"error": "Admin privileges required to change camera settings."} + + camera = arguments.get("camera", "").strip() + feature = arguments.get("feature", "").strip() + value = arguments.get("value", "").strip() + + if not camera or not feature or not value: + return {"error": "camera, feature, and value are all required."} + + dispatcher = request.app.dispatcher + frigate_config = request.app.frigate_config + + if feature == "profile": + if camera != "*": + return {"error": "Profile feature requires camera='*'."} + dispatcher._receive("profile/set", value) + return {"success": True, "camera": camera, "feature": feature, "value": value} + + if feature not in dispatcher._camera_settings_handlers: + return {"error": f"Unknown feature: {feature}"} + + if camera == "*": + cameras = list(frigate_config.cameras.keys()) + elif camera not in frigate_config.cameras: + return {"error": f"Camera '{camera}' not found."} + else: + cameras = [camera] + + for cam in cameras: + dispatcher._receive(f"{cam}/{feature}/set", value) + + return {"success": True, "camera": camera, "feature": feature, "value": value} + + async def _execute_tool_internal( tool_name: str, arguments: Dict[str, Any], @@ -398,6 +501,8 @@ async def _execute_tool_internal( except (json.JSONDecodeError, AttributeError) as e: logger.warning(f"Failed to extract tool result: {e}") return {"error": "Failed to parse tool result"} + elif tool_name == "set_camera_state": + return await _execute_set_camera_state(request, arguments) elif tool_name == "get_live_context": camera = arguments.get("camera") if not camera: diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 1640da739..45392a138 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -30,10 +30,8 @@ class AppPutRoleBody(BaseModel): role: str -class ProfileSetBody(BaseModel): - profile: Optional[str] = Field( - default=None, description="Profile name to activate, or null to deactivate" - ) +class CameraSetBody(BaseModel): + value: str = Field(..., description="The value to set for the feature") class MediaSyncBody(BaseModel): diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 7353a3035..dfe39be47 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -128,7 +128,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const handleActivateProfile = async (profileName: string | null) => { try { - await axios.put("profile/set", { profile: profileName || null }); + await axios.put("camera/*/set/profile", { value: profileName ?? "none" }); await updateProfiles(); toast.success( profileName diff --git a/web/src/views/settings/ProfilesView.tsx b/web/src/views/settings/ProfilesView.tsx index b9c00b6d5..3c4436015 100644 --- a/web/src/views/settings/ProfilesView.tsx +++ b/web/src/views/settings/ProfilesView.tsx @@ -207,8 +207,8 @@ export default function ProfilesView({ async (profile: string | null) => { setActivating(true); try { - await axios.put("profile/set", { - profile: profile || null, + await axios.put("camera/*/set/profile", { + value: profile ?? "none", }); await updateProfiles(); toast.success( @@ -244,7 +244,7 @@ export default function ProfilesView({ try { // If this profile is active, deactivate it first if (activeProfile === deleteProfile) { - await axios.put("profile/set", { profile: null }); + await axios.put("camera/*/set/profile", { value: "none" }); } // Remove the profile from all cameras and the top-level definition