Add ability to toggle camera features via API (#22538)
Some checks are pending
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* Refactor profile to be a generic state setter API

* Add tool to chat

* Cleanup

* Cleanup
This commit is contained in:
Nicolas Mowen 2026-03-19 16:39:28 -06:00 committed by GitHub
parent c6991db432
commit cedcbdba07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 185 additions and 28 deletions

View File

@ -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."""

View File

@ -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})

View File

@ -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:

View File

@ -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):

View File

@ -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

View File

@ -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