mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-23 23:14:46 +03:00
Compare commits
2
Commits
0453f945c7
...
39f76e4a2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f76e4a2c | ||
|
|
b6cf95a844 |
@@ -517,6 +517,6 @@ objects:
|
||||
7. If descriptions are generated but the results are poor or inconsistent, look at the model and the context window.
|
||||
- Empty fields, missing `shortSummary` values, or `Failed to parse review description` errors usually mean the model is not following the requested JSON schema. Smaller models struggle with structured output. Try a larger parameter size or one of the [recommended models](#recommended-local-models).
|
||||
- Frigate calculates how many frames to send from the context size the provider reports. If your server reports a different value than it is actually running with, frames will be truncated or the request will fail. Pin the value by adding `context_size` under <NavPath path="Settings > Enrichments > Generative AI > Provider options" /> (`genai.<provider>.provider_options`), and for Ollama also confirm `options.num_ctx` there matches the context you have configured.
|
||||
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="System metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
|
||||
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="Health and Metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
@@ -697,7 +697,7 @@ lpr:
|
||||
- You may need to adjust your `detection_threshold` if your plates are not being detected.
|
||||
|
||||
4. Ensure the characters on detected plates are being _recognized_.
|
||||
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="System metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
|
||||
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="Health and Metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
|
||||
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
|
||||
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the vehicle's label will change to the recognized plate when LPR is enabled and working.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
@@ -134,7 +134,7 @@ Along with picking a detector for your hardware, you will choose a model's **inp
|
||||
|
||||
**Resolution (320x320 vs 640x640):** Frigate is optimized for `320x320` models, and `320x320` is the best choice for the vast majority of setups. Frigate is specifically designed to compensate for the smaller model by cropping a region of motion from the full frame and zooming into it before running detection, so a `320x320` model is actually _better_ at small and distant objects, not worse. A `640x640` model is slower and uses more resources, and its main benefit is fitting more objects into a single inference when many objects are spread across a large area. Recent versions of Frigate have improved support for `640x640` models, but `320x320` remains the recommended starting point for nearly all setups.
|
||||
|
||||
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="System > Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
|
||||
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="Health and Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
|
||||
|
||||
**Acceptable inference time depends on your hardware.** Inference time alone does not tell the whole story, because different hardware has different capacity. A GPU can run multiple instances of the same model concurrently, so an inference time around 30ms can still keep up with several cameras. A Google Coral runs only a single instance of the model, so it needs a much lower inference time (around 10ms) to keep up.
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ The storage usage Frigate reports will not exactly match what the operating syst
|
||||
|
||||
### How Frigate measures recording usage
|
||||
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="Health and Metrics > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
|
||||
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_, not the drive's real free space, which will be lower whenever anything else is stored on the disk.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
```bash
|
||||
docker logs frigate
|
||||
```
|
||||
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the System Metrics page.
|
||||
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the Health and Metrics page.
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ You can still configure Frigate to use UDP by using ffmpeg input args or the pre
|
||||
|
||||
### Frigate is slow to start up with a "probing detect stream" message in the logs
|
||||
|
||||
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
|
||||
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under Health and Metrics.
|
||||
|
||||
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
|
||||
|
||||
@@ -167,3 +167,7 @@ Frigate's object detection relies on a machine learning [model](../frigate/gloss
|
||||
- If the false positive is always in the same fixed spot (like a statue or mailbox that reads as a person), add an [object filter mask](../configuration/masks.md#object-filter-masks) over that location.
|
||||
|
||||
Filters and masks only hide the incorrect result - they don't teach Frigate what the object actually is. For that, fine-tune your own model or use Frigate+.
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
Open System > Health. The Notices list shows problems the backend has noticed on its own, such as a camera whose ffmpeg keeps crashing, a detector that had to be restarted, a model download that failed, or recordings being deleted before their retention period. Entries that describe a one-time event can be dismissed; entries that describe an ongoing condition clear themselves once it is fixed.
|
||||
|
||||
@@ -15,7 +15,7 @@ When a stream won't play or behaves oddly, the most important first step is to f
|
||||
|
||||
### 1. Read the go2rtc logs
|
||||
|
||||
Access the go2rtc logs in the Frigate UI under <NavPath path="System Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
|
||||
Access the go2rtc logs in the Frigate UI under <NavPath path="Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
|
||||
|
||||
### 2. Test the stream in the go2rtc web interface
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor
|
||||
|
||||
:::tip
|
||||
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the Health and Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
Vendored
+102
@@ -4108,6 +4108,96 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Notices
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the active notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return event notices the user has dismissed
|
||||
|
||||
Returns:
|
||||
The active notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_dismissed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Include Dismissed
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/stats:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Notice Stats
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get lifetime occurrence counts per notice kind.
|
||||
operationId: get_notice_stats_notices_stats_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Dismiss Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide an event notice until it is raised again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/events:
|
||||
get:
|
||||
tags:
|
||||
@@ -7762,6 +7852,18 @@ components:
|
||||
description: Per-request thinking toggle. None means use the provider
|
||||
default. Ignored by providers that do not expose a per-request
|
||||
thinking switch.
|
||||
tool_decisions:
|
||||
additionalProperties:
|
||||
type: string
|
||||
enum:
|
||||
- approve
|
||||
- reject
|
||||
type: object
|
||||
title: Tool Decisions
|
||||
description: Decisions for tool calls that paused for approval, keyed
|
||||
by tool call ID. Send these with the conversation chain returned
|
||||
alongside an approval request; rejected calls are reported to the
|
||||
model as declined instead of being executed.
|
||||
type: object
|
||||
required:
|
||||
- messages
|
||||
|
||||
@@ -1301,6 +1301,9 @@ async def delete_camera(
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
|
||||
|
||||
if request.app.notice_registry is not None:
|
||||
request.app.notice_registry.resolve_camera(camera_name)
|
||||
|
||||
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
||||
|
||||
+495
-160
@@ -10,6 +10,7 @@ from functools import reduce
|
||||
from typing import Any, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -23,6 +24,7 @@ from frigate.api.chat_util import (
|
||||
chunk_content,
|
||||
distance_to_score,
|
||||
format_events_with_local_time,
|
||||
format_local_time,
|
||||
fuse_scores,
|
||||
hydrate_event,
|
||||
parse_iso_to_timestamp,
|
||||
@@ -33,29 +35,44 @@ from frigate.api.defs.response.chat_response import (
|
||||
ChatCompletionResponse,
|
||||
ChatMessageResponse,
|
||||
ToolCall,
|
||||
ToolCallInvocation,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.api.export import _build_export_job, _validate_export_source
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
get_tool_definitions,
|
||||
get_write_tool_names,
|
||||
strip_tool_access,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.genai.utils import (
|
||||
build_assistant_message_for_conversation,
|
||||
parse_tool_calls_from_message,
|
||||
)
|
||||
from frigate.jobs.export import ExportQueueFullError, start_export_job
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
start_vlm_watch_job,
|
||||
stop_vlm_watch_job,
|
||||
)
|
||||
from frigate.models import Event
|
||||
from frigate.models import Event, Export, ExportCase
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.chat])
|
||||
|
||||
# Tool result recorded for a rejected write tool call. Providers require a
|
||||
# result for every requested call; the user's intent is conveyed in a
|
||||
# follow-up user message built by _rejection_message.
|
||||
TOOL_REJECTED_RESULT: dict[str, str] = {"error": "user_rejected"}
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
@@ -666,29 +683,39 @@ async def _get_live_frame_image_url(
|
||||
frame = frame_processor.get_current_frame(camera, {})
|
||||
if frame is None:
|
||||
return None
|
||||
height, width = frame.shape[:2]
|
||||
target_height = 480
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
return _encode_frame_data_url(frame)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
||||
return None
|
||||
|
||||
|
||||
def _encode_frame_data_url(frame: np.ndarray, target_height: int = 480) -> str:
|
||||
"""Downscale a BGR frame and encode it as a JPEG data URL for the model."""
|
||||
height, width = frame.shape[:2]
|
||||
if height > target_height:
|
||||
scale = target_height / height
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), target_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def _request_roles(request: Request) -> list[str]:
|
||||
"""Roles from the auth proxy header, split on the configured separator."""
|
||||
separator = request.app.frigate_config.proxy.separator
|
||||
header = request.headers.get("remote-role", "")
|
||||
return [r.strip() for r in header.split(separator) if r.strip()]
|
||||
|
||||
|
||||
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(",")]:
|
||||
if "admin" not in _request_roles(request):
|
||||
return {"error": "Admin privileges required to change camera settings."}
|
||||
|
||||
camera = arguments.get("camera", "").strip()
|
||||
@@ -738,6 +765,189 @@ def _execute_get_categorized_object_names(
|
||||
return {"names": names}
|
||||
|
||||
|
||||
def _execute_get_export_cases(allowed_cameras: list[str]) -> dict[str, Any]:
|
||||
"""List export cases with how many accessible exports each one holds."""
|
||||
from peewee import fn
|
||||
|
||||
count_rows = (
|
||||
Export.select(Export.export_case, fn.COUNT(Export.id))
|
||||
.where(Export.camera << allowed_cameras, Export.export_case.is_null(False))
|
||||
.group_by(Export.export_case)
|
||||
.tuples()
|
||||
)
|
||||
counts = {case_id: count for case_id, count in count_rows}
|
||||
|
||||
cases: list[dict[str, Any]] = []
|
||||
for case in ExportCase.select().order_by(ExportCase.created_at.desc()):
|
||||
created_at = case.created_at
|
||||
cases.append(
|
||||
{
|
||||
"id": case.id,
|
||||
"name": case.name,
|
||||
"description": case.description,
|
||||
"created_at_local": format_local_time(created_at.timestamp())
|
||||
if isinstance(created_at, datetime)
|
||||
else str(created_at),
|
||||
"export_count": counts.get(case.id, 0),
|
||||
}
|
||||
)
|
||||
|
||||
if not cases:
|
||||
return {"cases": [], "message": "No export cases exist yet."}
|
||||
|
||||
return {"cases": cases}
|
||||
|
||||
|
||||
async def _execute_create_export(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a recording export, optionally attached to an existing case."""
|
||||
config = request.app.frigate_config
|
||||
camera = (arguments.get("camera") or "").strip()
|
||||
start_time = parse_iso_to_timestamp(arguments.get("start_time"))
|
||||
end_time = parse_iso_to_timestamp(arguments.get("end_time"))
|
||||
name = (arguments.get("name") or "").strip() or None
|
||||
|
||||
if not camera or start_time is None or end_time is None:
|
||||
return {"error": "camera, start_time, and end_time are all required."}
|
||||
|
||||
if camera not in config.cameras:
|
||||
return {"error": f"Camera '{camera}' not found."}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {"error": f"Camera '{camera}' not found or access denied"}
|
||||
|
||||
if end_time <= start_time:
|
||||
return {"error": "end_time must be after start_time."}
|
||||
|
||||
try:
|
||||
playback_source = PlaybackSourceEnum(arguments.get("source") or "recordings")
|
||||
except ValueError:
|
||||
return {"error": "source must be 'recordings' or 'preview'."}
|
||||
|
||||
# Mirror the export API: attaching to an existing case is admin-only
|
||||
# until case-level ACLs exist.
|
||||
export_case_id = (arguments.get("export_case_id") or "").strip() or None
|
||||
if export_case_id is not None:
|
||||
if "admin" not in _request_roles(request):
|
||||
return {"error": "Only admins can attach exports to an existing case."}
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == export_case_id)
|
||||
except ExportCase.DoesNotExist:
|
||||
return {"error": f"Export case '{export_case_id}' not found."}
|
||||
|
||||
source_error = _validate_export_source(
|
||||
camera, start_time, end_time, playback_source
|
||||
)
|
||||
if source_error is not None:
|
||||
return {"error": source_error}
|
||||
|
||||
export_job = _build_export_job(
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
name,
|
||||
None,
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=config.cameras[camera].record.export.chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(config, export_job)
|
||||
except ExportQueueFullError:
|
||||
return {"error": "Export queue is full. Try again once current exports finish."}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"export_id": export_job.id,
|
||||
"status": "queued",
|
||||
"camera": camera,
|
||||
"name": name,
|
||||
"source": playback_source.value,
|
||||
"start_time_local": format_local_time(start_time),
|
||||
"end_time_local": format_local_time(end_time),
|
||||
"export_case_id": export_case_id,
|
||||
"message": "Export queued. It will appear on the Export page when finished.",
|
||||
}
|
||||
|
||||
|
||||
async def _execute_get_event_image(
|
||||
request: Request,
|
||||
arguments: dict[str, Any],
|
||||
allowed_cameras: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Attach an event's thumbnail or snapshot for a vision model to view."""
|
||||
event_id = (arguments.get("event_id") or "").strip()
|
||||
if not event_id:
|
||||
return {"error": "event_id is required."}
|
||||
|
||||
image_type = arguments.get("image") or "thumbnail"
|
||||
if image_type not in ("thumbnail", "snapshot"):
|
||||
return {"error": "image must be 'thumbnail' or 'snapshot'."}
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
except Event.DoesNotExist:
|
||||
return {"error": f"Could not find event {event_id}."}
|
||||
|
||||
if event.camera not in allowed_cameras:
|
||||
return {"error": f"Event {event_id} not found or access denied"}
|
||||
|
||||
chat_client = request.app.genai_manager.chat_client
|
||||
if chat_client is None or not chat_client.supports_vision:
|
||||
return {
|
||||
"error": (
|
||||
"The configured chat model does not support vision, so images "
|
||||
"cannot be viewed."
|
||||
)
|
||||
}
|
||||
|
||||
note = None
|
||||
frame = None
|
||||
if image_type == "snapshot":
|
||||
if event.has_snapshot:
|
||||
frame, _ = load_event_snapshot_image(event)
|
||||
if frame is None:
|
||||
note = "Snapshot not available; returning the thumbnail instead."
|
||||
image_type = "thumbnail"
|
||||
|
||||
if frame is None:
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
if thumbnail:
|
||||
frame = cv2.imdecode(
|
||||
np.frombuffer(thumbnail, dtype=np.uint8), cv2.IMREAD_COLOR
|
||||
)
|
||||
|
||||
if frame is None:
|
||||
return {"error": f"No image is available for event {event_id}."}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": event.id,
|
||||
"camera": event.camera,
|
||||
"label": event.label,
|
||||
"sub_label": event.sub_label,
|
||||
"zones": event.zones,
|
||||
"start_time_local": format_local_time(event.start_time),
|
||||
"image": image_type,
|
||||
}
|
||||
if event.end_time is not None:
|
||||
result["end_time_local"] = format_local_time(event.end_time)
|
||||
description = (event.data or {}).get("description")
|
||||
if description:
|
||||
result["description"] = description
|
||||
if note:
|
||||
result["note"] = note
|
||||
|
||||
result["_image_url"] = _encode_frame_data_url(frame)
|
||||
result["_image_text"] = (
|
||||
f"Here is the {image_type} for event {event.id} "
|
||||
f"({event.sub_label or event.label} on {event.camera})."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_tool_internal(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
@@ -793,11 +1003,18 @@ async def _execute_tool_internal(
|
||||
return _execute_get_profile_status(request)
|
||||
elif tool_name == "get_recap":
|
||||
return _execute_get_recap(arguments, allowed_cameras)
|
||||
elif tool_name == "get_export_cases":
|
||||
return _execute_get_export_cases(allowed_cameras)
|
||||
elif tool_name == "create_export":
|
||||
return await _execute_create_export(request, arguments, allowed_cameras)
|
||||
elif tool_name == "get_event_image":
|
||||
return await _execute_get_event_image(request, arguments, allowed_cameras)
|
||||
else:
|
||||
logger.error(
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
||||
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
|
||||
"get_profile_status, get_recap. Arguments received: %s",
|
||||
"get_profile_status, get_recap, get_export_cases, create_export, get_event_image. "
|
||||
"Arguments received: %s",
|
||||
tool_name,
|
||||
json.dumps(arguments),
|
||||
)
|
||||
@@ -1026,14 +1243,74 @@ def _execute_get_recap(
|
||||
return {"error": "Failed to fetch recap data."}
|
||||
|
||||
|
||||
def _pending_tool_calls_from_tail(
|
||||
conversation: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Return the tool calls of a trailing assistant message, if any.
|
||||
|
||||
A conversation that ends with an assistant message requesting tools is a
|
||||
resume after an approval pause: the client sends the chain back with its
|
||||
decisions and the loop runs those calls before asking the model again.
|
||||
"""
|
||||
if not conversation:
|
||||
return None
|
||||
tail = conversation[-1]
|
||||
if tail.get("role") != "assistant" or not tail.get("tool_calls"):
|
||||
return None
|
||||
return parse_tool_calls_from_message(tail)
|
||||
|
||||
|
||||
def _tool_calls_awaiting_approval(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
body: ChatCompletionRequest,
|
||||
write_tools: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the write tool calls the user still has to decide on."""
|
||||
return [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": tc.get("arguments") or {},
|
||||
}
|
||||
for tc in pending_tool_calls
|
||||
if tc["name"] in write_tools and tc["id"] not in body.tool_decisions
|
||||
]
|
||||
|
||||
|
||||
def _rejection_message(tool_names: list[str]) -> dict[str, Any]:
|
||||
"""User message telling the model a rejected call should not proceed.
|
||||
|
||||
Uses list-form content so the UI, which only renders string user
|
||||
content, does not show it as something the user typed.
|
||||
"""
|
||||
names = ", ".join(name.replace("_", " ") for name in tool_names)
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"I do not want to proceed with the {names} call. Ask me for "
|
||||
"clarification or suggest adjustments instead of running it."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _execute_pending_tools(
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
request: Request,
|
||||
allowed_cameras: list[str],
|
||||
decisions: dict[str, str] | None = None,
|
||||
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls.
|
||||
|
||||
Calls the user rejected (per `decisions`) are not executed; they get a
|
||||
placeholder result and a user message saying not to proceed is appended
|
||||
after the tool results.
|
||||
|
||||
Returns:
|
||||
(ToolCall list for API response,
|
||||
tool result dicts for conversation,
|
||||
@@ -1042,10 +1319,28 @@ async def _execute_pending_tools(
|
||||
tool_calls_out: list[ToolCall] = []
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
extra_messages: list[dict[str, Any]] = []
|
||||
rejected_tools: list[str] = []
|
||||
for tool_call in pending_tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call.get("arguments") or {}
|
||||
tool_call_id = tool_call["id"]
|
||||
if decisions and decisions.get(tool_call_id) == "reject":
|
||||
logger.debug(
|
||||
"Tool %s (id: %s) was rejected by the user", tool_name, tool_call_id
|
||||
)
|
||||
rejected_tools.append(tool_name)
|
||||
rejected_content = json.dumps(TOOL_REJECTED_RESULT)
|
||||
tool_calls_out.append(
|
||||
ToolCall(name=tool_name, arguments=tool_args, response=rejected_content)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": rejected_content,
|
||||
}
|
||||
)
|
||||
continue
|
||||
logger.debug(
|
||||
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
|
||||
)
|
||||
@@ -1079,17 +1374,21 @@ async def _execute_pending_tools(
|
||||
if isinstance(evt, dict)
|
||||
]
|
||||
|
||||
# Extract _image_url from get_live_context results — images can
|
||||
# only be sent in user messages, not tool results
|
||||
# Extract _image_url from tool results — images can only be sent
|
||||
# in user messages, not tool results
|
||||
if isinstance(tool_result, dict) and "_image_url" in tool_result:
|
||||
image_url = tool_result.pop("_image_url")
|
||||
image_text = tool_result.pop("_image_text", None) or (
|
||||
"Here is the current live image from camera "
|
||||
f"'{tool_result.get('camera', 'unknown')}'."
|
||||
)
|
||||
extra_messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
|
||||
"text": image_text,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
@@ -1133,6 +1432,8 @@ async def _execute_pending_tools(
|
||||
"content": error_content,
|
||||
}
|
||||
)
|
||||
if rejected_tools:
|
||||
extra_messages.append(_rejection_message(rejected_tools))
|
||||
return (tool_calls_out, tool_results, extra_messages)
|
||||
|
||||
|
||||
@@ -1179,6 +1480,8 @@ async def chat_completion(
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
write_tools = get_write_tool_names(tools)
|
||||
llm_tools = strip_tool_access(tools)
|
||||
conversation = []
|
||||
|
||||
# Build the system message only when the client hasn't already pinned one.
|
||||
@@ -1217,6 +1520,10 @@ async def chat_completion(
|
||||
tool_calls: list[ToolCall] = []
|
||||
max_iterations = body.max_tool_iterations
|
||||
|
||||
# Resume after an approval pause: run the tail's tool calls (honoring the
|
||||
# client's decisions) before asking the model for anything new.
|
||||
resume_pending = _pending_tool_calls_from_tail(conversation)
|
||||
|
||||
logger.debug(
|
||||
f"Starting chat completion with {len(conversation)} message(s), "
|
||||
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
||||
@@ -1228,93 +1535,64 @@ async def chat_completion(
|
||||
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_iterations
|
||||
pending: list[dict[str, Any]] | None = resume_pending
|
||||
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None):
|
||||
def _emit(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload).encode("utf-8") + b"\n"
|
||||
|
||||
def _emit_chain(extra: list[dict[str, Any]] | None = None) -> bytes:
|
||||
# Return the full conversation (including the system message) so
|
||||
# the client persists and replays it verbatim next turn.
|
||||
chain = conversation + (extra or [])
|
||||
return (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
|
||||
+ b"\n"
|
||||
return _emit(
|
||||
{"type": "messages", "messages": conversation + (extra or [])}
|
||||
)
|
||||
|
||||
while stream_iterations < max_iterations:
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "reasoning_delta":
|
||||
yield (
|
||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "stats":
|
||||
yield (
|
||||
json.dumps({"type": "stats", **value}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield (
|
||||
json.dumps(
|
||||
|
||||
if pending is None:
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
):
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected, stopping chat stream")
|
||||
return
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield _emit({"type": "content", "delta": value})
|
||||
elif kind == "reasoning_delta":
|
||||
yield _emit({"type": "reasoning", "delta": value})
|
||||
elif kind == "stats":
|
||||
yield _emit({"type": "stats", **value})
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield _emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "An error occurred while processing your request.",
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
return
|
||||
pending = msg.get("tool_calls")
|
||||
if pending:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
logger.debug(
|
||||
"Client disconnected before tool execution"
|
||||
)
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
break
|
||||
else:
|
||||
requested = msg.get("tool_calls")
|
||||
if requested:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), requested
|
||||
)
|
||||
)
|
||||
pending = requested
|
||||
break
|
||||
# Streaming never appends the final assistant message
|
||||
# to the conversation, so add it to the chain.
|
||||
yield _emit_chain(
|
||||
@@ -1325,11 +1603,41 @@ async def chat_completion(
|
||||
}
|
||||
]
|
||||
)
|
||||
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
|
||||
yield _emit({"type": "done"})
|
||||
return
|
||||
else:
|
||||
if pending is None:
|
||||
# The stream ended without a final message; nothing
|
||||
# more to run.
|
||||
break
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(pending, body, write_tools)
|
||||
if awaiting:
|
||||
# Pause before running write tools. The client shows the
|
||||
# calls, collects decisions, and resends the chain.
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "approval_required", "tool_calls": awaiting})
|
||||
yield _emit({"type": "done"})
|
||||
return
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.debug("Client disconnected before tool execution")
|
||||
return
|
||||
(
|
||||
_executed_calls,
|
||||
tool_results,
|
||||
extra_msgs,
|
||||
) = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras, decisions=body.tool_decisions
|
||||
)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending = None
|
||||
# Emit the running chain so the client can render tool
|
||||
# calls live and replay them verbatim next turn.
|
||||
yield _emit_chain()
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
yield _emit_chain()
|
||||
yield _emit({"type": "done"})
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body_llm(),
|
||||
@@ -1338,102 +1646,129 @@ async def chat_completion(
|
||||
)
|
||||
|
||||
try:
|
||||
pending_tool_calls = resume_pending
|
||||
while tool_iterations < max_iterations:
|
||||
logger.debug(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
if pending_tool_calls is None:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=llm_tools if llm_tools else None,
|
||||
tool_choice="auto",
|
||||
enable_thinking=body.enable_thinking,
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
chain = list(conversation)
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
yield (
|
||||
json.dumps({"type": "messages", "messages": chain}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
chain = list(conversation)
|
||||
|
||||
async def stream_body() -> Any:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
{"type": "messages", "messages": chain}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": part}).encode(
|
||||
"utf-8"
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "content", "delta": part}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
|
||||
awaiting = _tool_calls_awaiting_approval(
|
||||
pending_tool_calls, body, write_tools
|
||||
)
|
||||
if awaiting:
|
||||
# Pause before running write tools; the client resends the
|
||||
# returned chain with its decisions to continue.
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
content=None,
|
||||
tool_calls=[ToolCallInvocation(**tc) for tc in awaiting],
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
finish_reason="approval_required",
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
messages=list(conversation),
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
|
||||
pending_tool_calls, request, allowed_cameras
|
||||
pending_tool_calls,
|
||||
request,
|
||||
allowed_cameras,
|
||||
decisions=body.tool_decisions,
|
||||
)
|
||||
tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
conversation.extend(extra_msgs)
|
||||
pending_tool_calls = None
|
||||
logger.debug(
|
||||
f"Added {len(tool_results)} tool result(s) to conversation. "
|
||||
f"Continuing with next LLM call..."
|
||||
|
||||
@@ -44,6 +44,11 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def format_local_time(timestamp: float) -> str:
|
||||
"""Format a unix timestamp as the server-local string quoted to users."""
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
|
||||
|
||||
def format_events_with_local_time(
|
||||
events_list: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -58,11 +63,9 @@ def format_events_with_local_time(
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
dt_start = datetime.fromtimestamp(start_ts)
|
||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
copy_evt["start_time_local"] = format_local_time(start_ts)
|
||||
if end_ts is not None:
|
||||
dt_end = datetime.fromtimestamp(end_ts)
|
||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
copy_evt["end_time_local"] = format_local_time(end_ts)
|
||||
except (TypeError, ValueError, OSError):
|
||||
pass
|
||||
result.append(copy_evt)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Chat API request models."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -59,3 +59,12 @@ class ChatCompletionRequest(BaseModel):
|
||||
"Ignored by providers that do not expose a per-request thinking switch."
|
||||
),
|
||||
)
|
||||
tool_decisions: dict[str, Literal["approve", "reject"]] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Decisions for tool calls that paused for approval, keyed by tool "
|
||||
"call ID. Send these with the conversation chain returned alongside "
|
||||
"an approval request; rejected calls are reported to the model as "
|
||||
"declined instead of being executed."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ class Tags(Enum):
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
motion_search = "Motion Search"
|
||||
notices = "Notices"
|
||||
notifications = "Notifications"
|
||||
preview = "Preview"
|
||||
recordings = "Recordings"
|
||||
|
||||
@@ -24,6 +24,7 @@ from frigate.api import (
|
||||
hardware,
|
||||
media,
|
||||
motion_search,
|
||||
notices,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
@@ -41,6 +42,7 @@ from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.storage import StorageMaintainer
|
||||
@@ -77,6 +79,7 @@ def create_fastapi_app(
|
||||
profile_manager: ProfileManager | None = None,
|
||||
enforce_default_admin: bool = True,
|
||||
config_holder: ConfigHolder | None = None,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
@@ -147,6 +150,7 @@ def create_fastapi_app(
|
||||
app.include_router(notification.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(hardware.router)
|
||||
app.include_router(notices.router)
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(motion_search.router)
|
||||
@@ -163,6 +167,7 @@ def create_fastapi_app(
|
||||
app.camera_error_image = None
|
||||
app.onvif = onvif
|
||||
app.stats_emitter = stats_emitter
|
||||
app.notice_registry = notice_registry
|
||||
app.event_metadata_updater = event_metadata_updater
|
||||
app.config_publisher = config_publisher
|
||||
app.replay_manager = replay_manager
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Notice APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.notices.registry import DismissResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get the active notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return event notices the user has dismissed
|
||||
|
||||
Returns:
|
||||
The active notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notices/stats", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notice_stats(request: Request) -> JSONResponse:
|
||||
"""Get lifetime occurrence counts per notice kind."""
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notices/{notice_id}/dismiss", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide an event notice until it is raised again."""
|
||||
result = request.app.notice_registry.dismiss(notice_id)
|
||||
|
||||
if result == DismissResult.not_found:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if result == DismissResult.not_dismissable:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "This notice clears itself when the problem is fixed",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
+18
-2
@@ -62,6 +62,8 @@ from frigate.log import _stop_logging
|
||||
from frigate.models import (
|
||||
Event,
|
||||
Export,
|
||||
Notice,
|
||||
NoticeStats,
|
||||
Previews,
|
||||
Recordings,
|
||||
RecordingsToDelete,
|
||||
@@ -71,6 +73,7 @@ from frigate.models import (
|
||||
Trigger,
|
||||
User,
|
||||
)
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.output.output import OutputProcess
|
||||
@@ -294,6 +297,8 @@ class FrigateApp:
|
||||
models = [
|
||||
Event,
|
||||
Export,
|
||||
Notice,
|
||||
NoticeStats,
|
||||
Previews,
|
||||
Recordings,
|
||||
RecordingsToDelete,
|
||||
@@ -305,6 +310,10 @@ class FrigateApp:
|
||||
]
|
||||
self.db.bind(models)
|
||||
|
||||
self.notice_registry = NoticeRegistry()
|
||||
# producers confirm state notices again at startup if they still hold
|
||||
self.notice_registry.mark_state_notices_unconfirmed()
|
||||
|
||||
def check_db_data_migrations(self) -> None:
|
||||
# check if vacuum needs to be run
|
||||
if not os.path.exists(f"{CONFIG_DIR}/.exports"):
|
||||
@@ -354,6 +363,7 @@ class FrigateApp:
|
||||
self.onvif_controller,
|
||||
self.ptz_metrics,
|
||||
comms,
|
||||
notice_registry=self.notice_registry,
|
||||
)
|
||||
self.dispatcher.start_communicators()
|
||||
|
||||
@@ -500,7 +510,9 @@ class FrigateApp:
|
||||
self.record_cleanup.start()
|
||||
|
||||
def start_storage_maintainer(self) -> None:
|
||||
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
|
||||
self.storage_maintainer = StorageMaintainer(
|
||||
self.config, self.stop_event, self.notice_registry
|
||||
)
|
||||
self.storage_maintainer.start()
|
||||
|
||||
def start_stats_emitter(self) -> None:
|
||||
@@ -514,11 +526,14 @@ class FrigateApp:
|
||||
self.processes,
|
||||
),
|
||||
self.stop_event,
|
||||
notice_registry=self.notice_registry,
|
||||
)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_watchdog(self) -> None:
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
self.frigate_watchdog = FrigateWatchdog(
|
||||
self.detectors, self.stop_event, self.notice_registry
|
||||
)
|
||||
|
||||
# (attribute on self, key in self.processes, factory)
|
||||
specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
|
||||
@@ -686,6 +701,7 @@ class FrigateApp:
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
config_holder=self.config_holder,
|
||||
notice_registry=self.notice_registry,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@@ -11,6 +11,7 @@ from peewee import IntegrityError
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
from frigate.config import (
|
||||
@@ -40,10 +41,12 @@ from frigate.const import (
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_JOB_STATE,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
||||
from frigate.types import ModelStatusTypesEnum, TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -67,12 +70,18 @@ class Dispatcher:
|
||||
onvif: OnvifController,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
communicators: list[Communicator],
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.config_updater = config_updater
|
||||
self.onvif = onvif
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.comms = communicators
|
||||
self.notice_registry = notice_registry
|
||||
|
||||
if notice_registry is not None:
|
||||
notice_registry.subscribe(self._publish_notices)
|
||||
|
||||
self.camera_activity = CameraActivityManager(config, self.publish)
|
||||
self.audio_activity = AudioActivityManager(config, self.publish)
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
@@ -212,6 +221,10 @@ class Dispatcher:
|
||||
False,
|
||||
)
|
||||
publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()), False)
|
||||
|
||||
if self.notice_registry is not None:
|
||||
publish("notices", json.dumps(self.notice_registry.active()), False)
|
||||
|
||||
publish("audio_detections", json.dumps(audio_detections), False)
|
||||
publish(
|
||||
"profile/state",
|
||||
@@ -339,6 +352,29 @@ class Dispatcher:
|
||||
self.model_state[model] = ModelStatusTypesEnum[state]
|
||||
self.publish("model_state", json.dumps(self.model_state))
|
||||
|
||||
def handle_update_notice() -> None:
|
||||
if self.notice_registry is None or not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
try:
|
||||
action = payload.get("action")
|
||||
kind = payload.get("kind")
|
||||
scope = payload.get("scope")
|
||||
|
||||
if not isinstance(kind, str):
|
||||
logger.warning("Ignoring notice update without a kind")
|
||||
elif action == "raise":
|
||||
self.notice_registry.raise_notice(
|
||||
kind, scope=scope, params=payload.get("params") or {}
|
||||
)
|
||||
elif action == "resolve":
|
||||
self.notice_registry.resolve(kind, scope)
|
||||
else:
|
||||
logger.warning("Ignoring notice update with action %s", action)
|
||||
except Exception:
|
||||
# a raise here would kill the REP thread for every process
|
||||
logger.exception("Failed to apply notice update")
|
||||
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
@@ -406,6 +442,7 @@ class Dispatcher:
|
||||
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_JOB_STATE: handle_update_job_state,
|
||||
UPDATE_NOTICE: handle_update_notice,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
@@ -464,6 +501,23 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
|
||||
Used for topics whose external schema is not settled yet.
|
||||
"""
|
||||
for comm in self.comms:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
return
|
||||
|
||||
self.publish_local("notices", json.dumps(self.notice_registry.active()))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from frigate.const import (
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
@@ -56,6 +57,7 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_NOTICE,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -158,6 +160,16 @@ _WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Topics only an admin connection may receive. unrestricted_only is not
|
||||
# enough: the built-in viewer role has an empty camera allow-list, which the
|
||||
# camera policy treats as full access, while the REST side of these topics
|
||||
# is admin-only.
|
||||
_WS_ADMIN_ONLY_TOPICS = frozenset(
|
||||
{
|
||||
"notices",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload (parsed as JSON) names a single owning camera at the
|
||||
# given key path. Used to scope events, reviews, triggers, etc.
|
||||
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
|
||||
@@ -280,6 +292,7 @@ def _classify_outbound(
|
||||
- "global" : send to every authenticated client
|
||||
- "drop" : send to nobody (fail-closed for unknowns)
|
||||
- "unrestricted_only" : send only to admin/full-access roles
|
||||
- "admin_only" : send only to connections with the admin role
|
||||
- "camera" : extra is the owning camera name
|
||||
- "payload_camera" : extra is the JSON key path to the camera name
|
||||
- "reshape_by_camera_key"
|
||||
@@ -288,6 +301,8 @@ def _classify_outbound(
|
||||
"""
|
||||
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
|
||||
return ("global", None)
|
||||
if topic in _WS_ADMIN_ONLY_TOPICS:
|
||||
return ("admin_only", None)
|
||||
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
|
||||
return ("unrestricted_only", None)
|
||||
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
|
||||
@@ -391,6 +406,9 @@ def _materialize_for_ws(
|
||||
if kind == "unrestricted_only":
|
||||
return full_message if _ws_is_unrestricted(ws, config) else None
|
||||
|
||||
if kind == "admin_only":
|
||||
return full_message if "admin" in _ws_valid_roles(ws, config) else None
|
||||
|
||||
if kind == "camera":
|
||||
return full_message if ws_has_camera_access(ws, extra, config) else None
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ UPDATE_MODEL_STATE = "update_model_state"
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
||||
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
|
||||
UPDATE_JOB_STATE = "update_job_state"
|
||||
UPDATE_NOTICE = "update_notice"
|
||||
NOTIFICATION_TEST = "notification_test"
|
||||
|
||||
# IO Nice Values
|
||||
|
||||
@@ -311,6 +311,10 @@ def get_tool_definitions(
|
||||
Descriptions here stay mechanical: which tool to reach for, and how the
|
||||
filters relate to each other, is stated once in the system prompt so the
|
||||
guidance is not paid for twice on every request.
|
||||
|
||||
Each definition carries a Frigate-only `access` field ("read" or "write");
|
||||
write tools pause for user approval in the chat loop. Strip it with
|
||||
`strip_tool_access` before sending the list to a provider.
|
||||
"""
|
||||
search_objects_properties: dict[str, Any] = {
|
||||
"camera": {
|
||||
@@ -382,6 +386,7 @@ def get_tool_definitions(
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
@@ -394,6 +399,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_categorized_object_names",
|
||||
"description": (
|
||||
@@ -411,6 +417,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
@@ -474,6 +481,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
@@ -529,6 +537,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
@@ -553,6 +562,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
@@ -596,6 +606,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": "Cancel the currently running watch job.",
|
||||
@@ -608,6 +619,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
@@ -624,6 +636,7 @@ def get_tool_definitions(
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
@@ -656,9 +669,120 @@ def get_tool_definitions(
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_export_cases",
|
||||
"description": (
|
||||
"List the export cases (named groups of exported clips) with "
|
||||
"their IDs, descriptions, and how many exports each holds. "
|
||||
"Call this before create_export when the user wants a clip "
|
||||
"added to an existing case."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "write",
|
||||
"function": {
|
||||
"name": "create_export",
|
||||
"description": (
|
||||
"Export a camera's recording for a time range to a "
|
||||
"downloadable file, optionally attached to an existing export "
|
||||
"case. Only call this when the user explicitly asks to export "
|
||||
"or save a clip."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to export from.",
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Start of the clip in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"description": "End of the clip in ISO 8601 format (e.g. '2025-03-15T08:05:00').",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Friendly name for the export (optional).",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["recordings", "preview"],
|
||||
"description": (
|
||||
"'recordings' (default) exports full-quality footage; "
|
||||
"'preview' builds a low-resolution timelapse."
|
||||
),
|
||||
"default": "recordings",
|
||||
},
|
||||
"export_case_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"ID of an existing export case to attach the export "
|
||||
"to. Use get_export_cases to find it."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera", "start_time", "end_time"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"access": "read",
|
||||
"function": {
|
||||
"name": "get_event_image",
|
||||
"description": (
|
||||
"View the thumbnail or snapshot image of a specific tracked "
|
||||
"object so you can describe what it shows. Use the event id "
|
||||
"from search_objects, find_similar_objects, or an attached "
|
||||
"event."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the tracked object to view.",
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"enum": ["thumbnail", "snapshot"],
|
||||
"description": (
|
||||
"'thumbnail' (default) is a small crop of the object; "
|
||||
"'snapshot' is the full camera frame."
|
||||
),
|
||||
"default": "thumbnail",
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_write_tool_names(tools: list[dict[str, Any]]) -> set[str]:
|
||||
"""Names of the tools whose `access` is "write" (they change state)."""
|
||||
return {tool["function"]["name"] for tool in tools if tool.get("access") == "write"}
|
||||
|
||||
|
||||
def strip_tool_access(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop the Frigate-only `access` field before handing tools to a provider."""
|
||||
return [{k: v for k, v in tool.items() if k != "access"} for tool in tools]
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
config: FrigateConfig,
|
||||
allowed_cameras: list[str],
|
||||
|
||||
@@ -183,3 +183,25 @@ class Trigger(Model):
|
||||
|
||||
class Meta:
|
||||
primary_key = CompositeKey("camera", "name")
|
||||
|
||||
|
||||
class Notice(Model):
|
||||
id = CharField(null=False, primary_key=True, max_length=150)
|
||||
kind = CharField(index=True, max_length=50)
|
||||
scope = CharField(max_length=100, null=True)
|
||||
params = JSONField()
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
count = IntegerField(default=1)
|
||||
dismissed_at = DateTimeField(null=True)
|
||||
|
||||
|
||||
class NoticeStats(Model):
|
||||
kind = CharField(null=False, primary_key=True, max_length=50)
|
||||
occurrences = IntegerField(default=0)
|
||||
dismissals = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_dismissals = IntegerField(default=0)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Registry of active notices and per-kind occurrence counts."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.types import (
|
||||
NOTICE_KINDS,
|
||||
SEVERITY_ORDER,
|
||||
NoticeMode,
|
||||
notice_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DismissResult(str, Enum):
|
||||
ok = "ok"
|
||||
not_found = "not_found"
|
||||
not_dismissable = "not_dismissable"
|
||||
|
||||
|
||||
# last_seen value of a state notice that has not been raised since startup
|
||||
UNCONFIRMED = 0
|
||||
|
||||
|
||||
class NoticeRegistry:
|
||||
"""Owns the notice tables. Lives in the main process only.
|
||||
|
||||
Producers in the main process call it directly. Other processes send an
|
||||
update_notice request that the dispatcher forwards here. The FastAPI app
|
||||
runs inside the main process too, so the API's dismiss shares this exact
|
||||
object, its lock, and its listeners.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
def subscribe(self, listener: Callable[[], None]) -> None:
|
||||
"""Call listener after every change to the active list."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def _notify(self) -> None:
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener()
|
||||
except Exception:
|
||||
logger.exception("Notice listener failed")
|
||||
|
||||
def raise_notice(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
scope: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Insert or update a notice. See the counting rule in the spec."""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
if definition is None:
|
||||
logger.warning("Ignoring notice of unknown kind %s", kind)
|
||||
return
|
||||
|
||||
if (
|
||||
definition.category == "camera"
|
||||
and scope
|
||||
and scope.startswith(REPLAY_CAMERA_PREFIX)
|
||||
):
|
||||
return
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
row_id = notice_id(kind, scope)
|
||||
params = params or {}
|
||||
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
params=params,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, now)
|
||||
elif definition.mode == NoticeMode.event:
|
||||
Notice.update(
|
||||
count=Notice.count + 1,
|
||||
last_seen=now,
|
||||
params=params,
|
||||
dismissed_at=None,
|
||||
).where(Notice.id == row_id).execute()
|
||||
self._bump_occurrences(kind, now)
|
||||
else:
|
||||
# one episode counts once; a repeat with nothing new is free
|
||||
confirmed = existing.last_seen != UNCONFIRMED
|
||||
|
||||
if confirmed and existing.params == params:
|
||||
return
|
||||
|
||||
Notice.update(last_seen=now, params=params).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
|
||||
def resolve(self, kind: str, scope: str | None = None) -> None:
|
||||
"""Delete a notice if present. Safe to call when it is absent."""
|
||||
with self._lock:
|
||||
deleted = (
|
||||
Notice.delete().where(Notice.id == notice_id(kind, scope)).execute()
|
||||
)
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop every camera-category notice scoped to a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
if definition.category == "camera"
|
||||
]
|
||||
|
||||
with self._lock:
|
||||
deleted = (
|
||||
Notice.delete()
|
||||
.where(
|
||||
Notice.kind.in_(camera_kinds), # type: ignore[call-arg, arg-type, misc]
|
||||
Notice.scope == camera,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def dismiss(self, row_id: str) -> DismissResult:
|
||||
"""Hide an event notice until it is raised again."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
return DismissResult.not_found
|
||||
|
||||
definition = NOTICE_KINDS.get(existing.kind)
|
||||
|
||||
if definition is None or definition.mode == NoticeMode.state:
|
||||
return DismissResult.not_dismissable
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.update(dismissed_at=now).where(Notice.id == row_id).execute()
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return DismissResult.ok
|
||||
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
"""The active notices, most severe first, then most recent first."""
|
||||
rows = []
|
||||
|
||||
for row in Notice.select():
|
||||
definition = NOTICE_KINDS.get(row.kind)
|
||||
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
continue
|
||||
|
||||
if definition.mode == NoticeMode.state and row.last_seen == UNCONFIRMED:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"mode": definition.mode.value,
|
||||
"severity": definition.severity.value,
|
||||
"category": definition.category,
|
||||
"scope": row.scope,
|
||||
"params": row.params,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
"dismissed_at": row.dismissed_at,
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(
|
||||
key=lambda n: (
|
||||
SEVERITY_ORDER[NOTICE_KINDS[n["kind"]].severity],
|
||||
-n["last_seen"],
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def stats(self) -> list[dict[str, Any]]:
|
||||
"""Lifetime counts per kind, for the UI meta line and later analytics."""
|
||||
return [
|
||||
{
|
||||
"kind": row.kind,
|
||||
"occurrences": row.occurrences,
|
||||
"dismissals": row.dismissals,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"reported_occurrences": row.reported_occurrences,
|
||||
"reported_dismissals": row.reported_dismissals,
|
||||
}
|
||||
for row in NoticeStats.select()
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def mark_state_notices_unconfirmed(self) -> None:
|
||||
"""Hide state notices until their producer raises them again.
|
||||
|
||||
Rows already unconfirmed from the previous run are deleted. Hiding
|
||||
rather than deleting keeps one episode that spans a restart at one
|
||||
occurrence.
|
||||
"""
|
||||
state_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
if definition.mode == NoticeMode.state
|
||||
]
|
||||
|
||||
with self._lock:
|
||||
Notice.delete().where(
|
||||
Notice.kind.in_(state_kinds), # type: ignore[call-arg, arg-type, misc]
|
||||
Notice.last_seen == UNCONFIRMED,
|
||||
).execute()
|
||||
Notice.update(last_seen=UNCONFIRMED).where(
|
||||
Notice.kind.in_(state_kinds) # type: ignore[call-arg, arg-type, misc]
|
||||
).execute()
|
||||
|
||||
def _bump_occurrences(self, kind: str, now: float) -> None:
|
||||
# called with the lock held
|
||||
stats = NoticeStats.get_or_none(NoticeStats.kind == kind)
|
||||
|
||||
if stats is None:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=1,
|
||||
dismissals=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
else:
|
||||
NoticeStats.update(
|
||||
occurrences=NoticeStats.occurrences + 1, last_seen=now
|
||||
).where(NoticeStats.kind == kind).execute()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Notice kinds and the definitions that fix their mode, severity, and category."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class NoticeMode(str, Enum):
|
||||
"""Whether a producer resolves the notice itself or the user dismisses it."""
|
||||
|
||||
state = "state"
|
||||
event = "event"
|
||||
|
||||
|
||||
class NoticeSeverity(str, Enum):
|
||||
error = "error"
|
||||
warning = "warning"
|
||||
info = "info"
|
||||
|
||||
|
||||
SEVERITY_ORDER: dict[NoticeSeverity, int] = {
|
||||
NoticeSeverity.error: 0,
|
||||
NoticeSeverity.warning: 1,
|
||||
NoticeSeverity.info: 2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeKind:
|
||||
"""One kind of notice. Producers pass the key; everything else comes from here.
|
||||
|
||||
Attributes:
|
||||
key: Stable machine-readable name, also the translation key suffix
|
||||
mode: state notices are resolved by their producer, event notices by the user
|
||||
severity: error, warning, or info
|
||||
category: what the scope names: camera, detector, and model kinds carry a scope
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
|
||||
key: str
|
||||
mode: NoticeMode
|
||||
severity: NoticeSeverity
|
||||
category: str
|
||||
reportable: bool = True
|
||||
|
||||
|
||||
SCOPED_CATEGORIES = frozenset({"camera", "detector", "model"})
|
||||
|
||||
_KINDS = (
|
||||
NoticeKind("ffmpeg_crash_loop", NoticeMode.state, NoticeSeverity.error, "camera"),
|
||||
NoticeKind("detector_stuck", NoticeMode.event, NoticeSeverity.warning, "detector"),
|
||||
NoticeKind(
|
||||
"model_download_failed", NoticeMode.event, NoticeSeverity.error, "model"
|
||||
),
|
||||
NoticeKind("retention_unmet", NoticeMode.state, NoticeSeverity.error, "storage"),
|
||||
NoticeKind("update_available", NoticeMode.state, NoticeSeverity.info, "system"),
|
||||
)
|
||||
|
||||
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
|
||||
|
||||
|
||||
def notice_id(kind: str, scope: str | None) -> str:
|
||||
"""The row id for a kind and optional scope."""
|
||||
return f"{kind}:{scope}" if scope else kind
|
||||
@@ -11,16 +11,21 @@ from typing import Any
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import FREQUENCY_STATS_POINTS
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.hardware import HardwareStats
|
||||
from frigate.stats.prometheus import update_metrics
|
||||
from frigate.stats.util import stats_snapshot
|
||||
from frigate.stats.util import get_latest_version, is_newer_version, stats_snapshot
|
||||
from frigate.types import StatsTrackingTypes
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MAX_STATS_POINTS = 80
|
||||
|
||||
# how often to ask GitHub for the latest release
|
||||
VERSION_REFRESH_S = 24 * 60 * 60
|
||||
|
||||
|
||||
class StatsEmitter(threading.Thread):
|
||||
def __init__(
|
||||
@@ -28,11 +33,13 @@ class StatsEmitter(threading.Thread):
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
super().__init__(name="frigate_stats_emitter")
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
|
||||
@@ -125,14 +132,54 @@ class StatsEmitter(threading.Thread):
|
||||
update_metrics(stats)
|
||||
return stats
|
||||
|
||||
def _check_update_notice(self) -> None:
|
||||
"""Raise or resolve the update notice from the tracked latest version."""
|
||||
if self.notice_registry is None:
|
||||
return
|
||||
|
||||
latest = self.stats_tracking["latest_frigate_version"]
|
||||
|
||||
# a failed lookup says nothing about whether an update exists
|
||||
if latest == "unknown":
|
||||
return
|
||||
|
||||
if is_newer_version(VERSION, latest):
|
||||
self.notice_registry.raise_notice(
|
||||
"update_available", params={"version": latest}
|
||||
)
|
||||
else:
|
||||
self.notice_registry.resolve("update_available")
|
||||
|
||||
def _refresh_latest_version(self) -> None:
|
||||
"""Refresh the latest release on a daemon thread so the request never stalls stats."""
|
||||
|
||||
def refresh() -> None:
|
||||
latest = get_latest_version(self.config)
|
||||
|
||||
# keep the last good value; stats surface it as service.latest_version
|
||||
if latest != "unknown":
|
||||
self.stats_tracking["latest_frigate_version"] = latest
|
||||
|
||||
self._check_update_notice()
|
||||
|
||||
threading.Thread(
|
||||
target=refresh, name="frigate_version_check", daemon=True
|
||||
).start()
|
||||
|
||||
def run(self) -> None:
|
||||
time.sleep(10)
|
||||
self._check_update_notice()
|
||||
last_version_check = time.time()
|
||||
for counter in itertools.cycle(
|
||||
range(int(self.config.mqtt.stats_interval / FREQUENCY_STATS_POINTS))
|
||||
):
|
||||
if self.stop_event.wait(FREQUENCY_STATS_POINTS):
|
||||
break
|
||||
|
||||
if time.time() - last_version_check >= VERSION_REFRESH_S:
|
||||
self._refresh_latest_version()
|
||||
last_version_check = time.time()
|
||||
|
||||
logger.debug("Starting stats collection")
|
||||
stats = stats_snapshot(
|
||||
self.config, self.stats_tracking, self.hardware_stats
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Utilities for stats."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
@@ -45,6 +46,42 @@ def get_latest_version(config: FrigateConfig) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, int, int] | None:
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value or "")
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
# a build's git hash suffix is not a prerelease marker
|
||||
PRERELEASE_PATTERN = re.compile(r"^\d+\.\d+\.\d+-(?:beta|rc)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_prerelease(value: str) -> bool:
|
||||
return PRERELEASE_PATTERN.match(value or "") is not None
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""Whether latest is a release newer than the running version.
|
||||
|
||||
Build suffixes after the third number are ignored, and a value that does
|
||||
not parse (disabled, unknown) is never newer. A prerelease is behind the
|
||||
final release of the same number, so 0.19.0-beta2 is behind 0.19.0.
|
||||
"""
|
||||
current_tuple = _version_tuple(current)
|
||||
latest_tuple = _version_tuple(latest)
|
||||
|
||||
if current_tuple is None or latest_tuple is None:
|
||||
return False
|
||||
|
||||
if current_tuple == latest_tuple:
|
||||
return _is_prerelease(current) and not _is_prerelease(latest)
|
||||
|
||||
return latest_tuple > current_tuple
|
||||
|
||||
|
||||
def stats_init(
|
||||
config: FrigateConfig,
|
||||
camera_metrics: DictProxy,
|
||||
|
||||
+42
-18
@@ -20,6 +20,7 @@ from frigate.const import (
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.util.builtin import clear_and_unlink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,10 +35,16 @@ BANDWIDTH_SAMPLE_TARGET = 50
|
||||
class StorageMaintainer(threading.Thread):
|
||||
"""Maintain frigates recording storage."""
|
||||
|
||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="storage_maintainer")
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self.camera_storage_stats: dict[str, dict] = {}
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
@@ -342,9 +349,21 @@ class StorageMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
# this file was not found so we must assume no space was cleaned up
|
||||
pass
|
||||
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.raise_notice(
|
||||
"retention_unmet",
|
||||
params={
|
||||
"needed_mb": round(float(hourly_bandwidth), 1),
|
||||
"cleared_mb": round(float(deleted_segments_size), 1),
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(f"Cleaned up {deleted_segments_size:.2f} MB of recordings")
|
||||
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.resolve("retention_unmet")
|
||||
|
||||
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
|
||||
# delete up to 100,000 at a time
|
||||
max_deletes = 100000
|
||||
@@ -406,23 +425,28 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
self.calculate_camera_bandwidth()
|
||||
while not self.stop_event.wait(300):
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
|
||||
if camera in self.camera_storage_stats:
|
||||
self.camera_storage_stats[camera]["needs_refresh"] = True
|
||||
|
||||
if not self.camera_storage_stats or True in [
|
||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||
]:
|
||||
self.calculate_camera_bandwidth()
|
||||
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
|
||||
|
||||
if self.check_storage_needs_cleanup():
|
||||
logger.info(
|
||||
"Less than 1 hour of recording space left, running storage maintenance..."
|
||||
)
|
||||
self.reduce_storage_consumption()
|
||||
self._maintain_once()
|
||||
|
||||
self.config_subscriber.stop()
|
||||
logger.info("Exiting storage maintainer...")
|
||||
|
||||
def _maintain_once(self) -> None:
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
|
||||
if camera in self.camera_storage_stats:
|
||||
self.camera_storage_stats[camera]["needs_refresh"] = True
|
||||
|
||||
if not self.camera_storage_stats or True in [
|
||||
r["needs_refresh"] for r in self.camera_storage_stats.values()
|
||||
]:
|
||||
self.calculate_camera_bandwidth()
|
||||
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
|
||||
|
||||
if self.check_storage_needs_cleanup():
|
||||
logger.info(
|
||||
"Less than 1 hour of recording space left, running storage maintenance..."
|
||||
)
|
||||
self.reduce_storage_consumption()
|
||||
elif self.notice_registry is not None:
|
||||
self.notice_registry.resolve("retention_unmet")
|
||||
|
||||
@@ -144,7 +144,9 @@ class BaseTestHttp(unittest.TestCase):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def create_app(self, stats=None, event_metadata_publisher=None):
|
||||
def create_app(
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
app = create_fastapi_app(
|
||||
@@ -159,6 +161,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
# Default test mocks for authentication
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for the notices API."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpNotices(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Notice, NoticeStats])
|
||||
self.registry = NoticeRegistry()
|
||||
self.app = self.create_app(notice_registry=self.registry)
|
||||
|
||||
def client(self) -> TestClient:
|
||||
return AuthTestClient(self.app)
|
||||
|
||||
def test_get_notices_orders_by_severity(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
[n["id"] for n in response.json()], ["retention_unmet", "detector_stuck"]
|
||||
)
|
||||
self.assertEqual(response.json()[0]["severity"], "error")
|
||||
|
||||
def test_get_stats(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.get("/notices/stats")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
|
||||
self.assertEqual(response.json()[0]["occurrences"], 1)
|
||||
|
||||
def test_dismiss_event(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/detector_stuck/dismiss")
|
||||
listed = client.get("/notices").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(listed, [])
|
||||
|
||||
def test_dismiss_unknown_is_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/nope/dismiss")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_dismiss_state_is_400(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/retention_unmet/dismiss")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -6,62 +6,72 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
||||
from frigate.video.ffmpeg import CameraWatchdog
|
||||
from frigate.video.ffmpeg import (
|
||||
CRASH_LOOP_RECOVERY_S,
|
||||
CRASH_LOOP_RESTARTS,
|
||||
CameraWatchdog,
|
||||
)
|
||||
|
||||
|
||||
def build_watchdog(
|
||||
sub_enabled: bool = True, output_args: dict | None = None
|
||||
) -> CameraWatchdog:
|
||||
config = FrigateConfig(
|
||||
**{
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"output_args": output_args or {},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["record"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["detect", "record_sub"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"record": {
|
||||
"enabled": True,
|
||||
"sub": {"enabled": sub_enabled},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
camera_config = config.cameras["front_door"]
|
||||
|
||||
with (
|
||||
patch("frigate.video.ffmpeg.LogPipe"),
|
||||
patch("frigate.video.ffmpeg.InterProcessRequestor"),
|
||||
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
|
||||
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
|
||||
):
|
||||
watchdog = CameraWatchdog(
|
||||
camera_config,
|
||||
1,
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
watchdog.requestor = MagicMock()
|
||||
return watchdog
|
||||
|
||||
|
||||
class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def _build_watchdog(
|
||||
self, sub_enabled: bool = True, output_args: dict | None = None
|
||||
) -> CameraWatchdog:
|
||||
config = FrigateConfig(
|
||||
**{
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"output_args": output_args or {},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["record"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["detect", "record_sub"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"record": {
|
||||
"enabled": True,
|
||||
"sub": {"enabled": sub_enabled},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
camera_config = config.cameras["front_door"]
|
||||
|
||||
with (
|
||||
patch("frigate.video.ffmpeg.LogPipe"),
|
||||
patch("frigate.video.ffmpeg.InterProcessRequestor"),
|
||||
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
|
||||
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
|
||||
):
|
||||
watchdog = CameraWatchdog(
|
||||
camera_config,
|
||||
1,
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
watchdog.requestor = MagicMock()
|
||||
return watchdog
|
||||
return build_watchdog(sub_enabled, output_args)
|
||||
|
||||
def test_stale_sub_does_not_mark_main_stale(self):
|
||||
watchdog = self._build_watchdog()
|
||||
@@ -218,3 +228,75 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
|
||||
assert watchdog.record_stale_threshold[STREAM_TYPE_MAIN] == 120
|
||||
assert watchdog.record_stale_threshold[STREAM_TYPE_SUB] == 150
|
||||
|
||||
|
||||
class TestCameraWatchdogCrashLoop(unittest.TestCase):
|
||||
"""The crash loop notice follows restarts in the trailing hour."""
|
||||
|
||||
def _build_watchdog(self) -> CameraWatchdog:
|
||||
return build_watchdog()
|
||||
|
||||
def _notice_payloads(self, watchdog: CameraWatchdog) -> list[dict]:
|
||||
return [
|
||||
call.args[1]
|
||||
for call in watchdog.requestor.send_data.call_args_list
|
||||
if call.args[0] == "update_notice"
|
||||
]
|
||||
|
||||
def test_below_threshold_raises_nothing(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
watchdog.reconnect_timestamps.extend([now] * (CRASH_LOOP_RESTARTS - 1))
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
self.assertEqual(self._notice_payloads(watchdog), [])
|
||||
|
||||
def test_threshold_raises_once_per_restart_count(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(len(payloads), 1)
|
||||
self.assertEqual(payloads[0]["action"], "raise")
|
||||
self.assertEqual(payloads[0]["kind"], "ffmpeg_crash_loop")
|
||||
self.assertEqual(payloads[0]["scope"], "front_door")
|
||||
self.assertEqual(payloads[0]["params"], {"restarts": CRASH_LOOP_RESTARTS})
|
||||
|
||||
def test_recovery_window_resolves(self):
|
||||
watchdog = self._build_watchdog()
|
||||
now = datetime.now().timestamp()
|
||||
# the last restart is at `now`, so recovery is measured from there
|
||||
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
watchdog._update_crash_loop_notice(now)
|
||||
|
||||
watchdog.capture_thread.is_alive.return_value = True
|
||||
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S - 1)
|
||||
self.assertEqual(len(self._notice_payloads(watchdog)), 1)
|
||||
|
||||
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S + 1)
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(len(payloads), 2)
|
||||
self.assertEqual(payloads[1]["action"], "resolve")
|
||||
self.assertFalse(watchdog.crash_loop_raised)
|
||||
|
||||
def test_disable_resolves(self):
|
||||
watchdog = self._build_watchdog()
|
||||
watchdog.crash_loop_raised = True
|
||||
|
||||
watchdog._resolve_crash_loop()
|
||||
|
||||
payloads = self._notice_payloads(watchdog)
|
||||
self.assertEqual(payloads[-1]["action"], "resolve")
|
||||
self.assertFalse(watchdog.crash_loop_raised)
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Tests for chat tool approval and the export and event image tools."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.api.chat import (
|
||||
TOOL_REJECTED_RESULT,
|
||||
_execute_create_export,
|
||||
_execute_get_event_image,
|
||||
_execute_get_export_cases,
|
||||
_execute_pending_tools,
|
||||
_pending_tool_calls_from_tail,
|
||||
_tool_calls_awaiting_approval,
|
||||
)
|
||||
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
||||
from frigate.genai.prompts import (
|
||||
get_tool_definitions,
|
||||
get_write_tool_names,
|
||||
strip_tool_access,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.export import ExportQueueFullError
|
||||
from frigate.models import Event, Export, ExportCase, Previews, Recordings
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _request(role: str = "admin", supports_vision: bool = True):
|
||||
camera = SimpleNamespace(
|
||||
record=SimpleNamespace(export=SimpleNamespace(chapters=None)),
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
frigate_config=SimpleNamespace(
|
||||
cameras={"driveway": camera, "garage": camera},
|
||||
proxy=SimpleNamespace(separator=","),
|
||||
),
|
||||
genai_manager=SimpleNamespace(
|
||||
chat_client=SimpleNamespace(supports_vision=supports_vision),
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(app=app, headers={"remote-role": role})
|
||||
|
||||
|
||||
def _body(**kwargs) -> ChatCompletionRequest:
|
||||
return ChatCompletionRequest(messages=[], **kwargs)
|
||||
|
||||
|
||||
WRITE_TOOLS = get_write_tool_names(get_tool_definitions())
|
||||
|
||||
|
||||
class TestToolRegistry(unittest.TestCase):
|
||||
def test_every_tool_declares_access(self):
|
||||
for tool in get_tool_definitions():
|
||||
self.assertIn(tool.get("access"), ("read", "write"), tool)
|
||||
|
||||
def test_write_tools(self):
|
||||
self.assertEqual(
|
||||
WRITE_TOOLS,
|
||||
{
|
||||
"set_camera_state",
|
||||
"start_camera_watch",
|
||||
"stop_camera_watch",
|
||||
"create_export",
|
||||
},
|
||||
)
|
||||
|
||||
def test_strip_tool_access_removes_frigate_field(self):
|
||||
for tool in strip_tool_access(get_tool_definitions()):
|
||||
self.assertNotIn("access", tool)
|
||||
self.assertEqual(set(tool), {"type", "function"})
|
||||
|
||||
def test_new_tools_are_registered(self):
|
||||
names = {t["function"]["name"] for t in get_tool_definitions()}
|
||||
self.assertIn("get_export_cases", names)
|
||||
self.assertIn("create_export", names)
|
||||
self.assertIn("get_event_image", names)
|
||||
|
||||
def test_create_export_requires_time_range(self):
|
||||
tool = next(
|
||||
t
|
||||
for t in get_tool_definitions()
|
||||
if t["function"]["name"] == "create_export"
|
||||
)
|
||||
params = tool["function"]["parameters"]
|
||||
self.assertEqual(params["required"], ["camera", "start_time", "end_time"])
|
||||
self.assertNotIn("event_id", params["properties"])
|
||||
self.assertNotIn("new_case_name", params["properties"])
|
||||
|
||||
def test_get_event_image_requires_event_id(self):
|
||||
tool = next(
|
||||
t
|
||||
for t in get_tool_definitions()
|
||||
if t["function"]["name"] == "get_event_image"
|
||||
)
|
||||
self.assertEqual(tool["function"]["parameters"]["required"], ["event_id"])
|
||||
|
||||
|
||||
class TestApprovalHelpers(unittest.TestCase):
|
||||
def test_tail_without_tool_calls_is_not_pending(self):
|
||||
self.assertIsNone(_pending_tool_calls_from_tail([]))
|
||||
self.assertIsNone(
|
||||
_pending_tool_calls_from_tail([{"role": "user", "content": "hi"}])
|
||||
)
|
||||
self.assertIsNone(
|
||||
_pending_tool_calls_from_tail([{"role": "assistant", "content": "ok"}])
|
||||
)
|
||||
|
||||
def test_tail_with_tool_calls_is_parsed(self):
|
||||
tail = build_assistant_message_for_conversation(
|
||||
None, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
|
||||
)
|
||||
pending = _pending_tool_calls_from_tail([{"role": "user"}, tail])
|
||||
self.assertEqual(
|
||||
pending, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
|
||||
)
|
||||
|
||||
def test_read_tools_never_await_approval(self):
|
||||
pending = [{"id": "c1", "name": "search_objects", "arguments": {}}]
|
||||
self.assertEqual(
|
||||
_tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS), []
|
||||
)
|
||||
|
||||
def test_write_tools_await_approval(self):
|
||||
pending = [
|
||||
{"id": "c1", "name": "search_objects", "arguments": {}},
|
||||
{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}},
|
||||
]
|
||||
awaiting = _tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS)
|
||||
self.assertEqual(
|
||||
awaiting,
|
||||
[{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}}],
|
||||
)
|
||||
|
||||
def test_decided_calls_skip_approval(self):
|
||||
pending = [
|
||||
{"id": "c2", "name": "create_export", "arguments": {}},
|
||||
{"id": "c3", "name": "set_camera_state", "arguments": {}},
|
||||
]
|
||||
body = _body(tool_decisions={"c2": "approve", "c3": "reject"})
|
||||
self.assertEqual(_tool_calls_awaiting_approval(pending, body, WRITE_TOOLS), [])
|
||||
|
||||
|
||||
class TestExecutePendingTools(unittest.TestCase):
|
||||
def test_rejected_call_is_not_executed(self):
|
||||
execute = AsyncMock(return_value={"success": True})
|
||||
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
calls, results, extra = _run(
|
||||
_execute_pending_tools(
|
||||
pending, _request(), ["driveway"], decisions={"c1": "reject"}
|
||||
)
|
||||
)
|
||||
execute.assert_not_called()
|
||||
self.assertEqual(json.loads(results[0]["content"]), TOOL_REJECTED_RESULT)
|
||||
self.assertEqual(results[0]["tool_call_id"], "c1")
|
||||
self.assertEqual(calls[0].name, "create_export")
|
||||
# The user's intent goes to the model as a follow-up user message.
|
||||
self.assertEqual(len(extra), 1)
|
||||
self.assertEqual(extra[0]["role"], "user")
|
||||
text = extra[0]["content"][0]["text"]
|
||||
self.assertIn("do not want to proceed", text)
|
||||
self.assertIn("create export", text)
|
||||
self.assertIn("clarification", text)
|
||||
|
||||
def test_approved_call_is_executed(self):
|
||||
execute = AsyncMock(return_value={"success": True})
|
||||
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
_calls, results, _extra = _run(
|
||||
_execute_pending_tools(
|
||||
pending, _request(), ["driveway"], decisions={"c1": "approve"}
|
||||
)
|
||||
)
|
||||
execute.assert_awaited_once()
|
||||
self.assertEqual(json.loads(results[0]["content"]), {"success": True})
|
||||
|
||||
def test_image_text_becomes_user_message(self):
|
||||
execute = AsyncMock(
|
||||
return_value={
|
||||
"id": "evt",
|
||||
"_image_url": "data:image/jpeg;base64,xx",
|
||||
"_image_text": "Here is the thumbnail.",
|
||||
}
|
||||
)
|
||||
pending = [{"id": "c1", "name": "get_event_image", "arguments": {}}]
|
||||
with patch("frigate.api.chat._execute_tool_internal", execute):
|
||||
_calls, results, extra = _run(
|
||||
_execute_pending_tools(pending, _request(), ["driveway"])
|
||||
)
|
||||
self.assertEqual(json.loads(results[0]["content"]), {"id": "evt"})
|
||||
self.assertEqual(extra[0]["role"], "user")
|
||||
self.assertEqual(extra[0]["content"][0]["text"], "Here is the thumbnail.")
|
||||
self.assertEqual(
|
||||
extra[0]["content"][1]["image_url"]["url"], "data:image/jpeg;base64,xx"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseTestCase(unittest.TestCase):
|
||||
models = [Event, Export, ExportCase, Recordings, Previews]
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
self.tmp.close()
|
||||
self.db = SqliteExtDatabase(self.tmp.name)
|
||||
for model in self.models:
|
||||
model.bind(self.db, bind_refs=False, bind_backrefs=False)
|
||||
self.db.connect()
|
||||
self.db.create_tables(self.models)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
os.unlink(self.tmp.name)
|
||||
|
||||
def make_event(self, event_id, camera="driveway", thumbnail="", **overrides):
|
||||
fields = dict(
|
||||
id=event_id,
|
||||
label="car",
|
||||
sub_label=None,
|
||||
camera=camera,
|
||||
start_time=1_700_000_100,
|
||||
end_time=1_700_000_110,
|
||||
top_score=0.9,
|
||||
score=0.9,
|
||||
false_positive=False,
|
||||
zones=[],
|
||||
thumbnail=thumbnail,
|
||||
has_clip=True,
|
||||
has_snapshot=False,
|
||||
region=[0, 0, 1, 1],
|
||||
box=[0, 0, 1, 1],
|
||||
area=1,
|
||||
retain_indefinitely=False,
|
||||
ratio=1.0,
|
||||
plus_id="",
|
||||
model_hash="",
|
||||
detector_type="",
|
||||
model_type="",
|
||||
data={},
|
||||
)
|
||||
fields.update(overrides)
|
||||
return Event.create(**fields)
|
||||
|
||||
def make_case(self, case_id, name="Case"):
|
||||
now = datetime.fromtimestamp(1_700_000_000)
|
||||
return ExportCase.create(
|
||||
id=case_id, name=name, description=None, created_at=now, updated_at=now
|
||||
)
|
||||
|
||||
|
||||
def _jpeg_base64() -> str:
|
||||
frame = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
_, encoded = cv2.imencode(".jpg", frame)
|
||||
return base64.b64encode(encoded.tobytes()).decode("utf-8")
|
||||
|
||||
|
||||
class TestGetEventImage(DatabaseTestCase):
|
||||
def test_requires_vision(self):
|
||||
self.make_event("evt", thumbnail=_jpeg_base64())
|
||||
result = _run(
|
||||
_execute_get_event_image(
|
||||
_request(supports_vision=False), {"event_id": "evt"}, ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("vision", result["error"])
|
||||
|
||||
def test_unknown_event(self):
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "nope"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("nope", result["error"])
|
||||
|
||||
def test_camera_access_denied(self):
|
||||
self.make_event("evt", camera="garage", thumbnail=_jpeg_base64())
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("access denied", result["error"])
|
||||
|
||||
def test_thumbnail_is_attached(self):
|
||||
self.make_event(
|
||||
"evt", thumbnail=_jpeg_base64(), data={"description": "a red car"}
|
||||
)
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertEqual(result["id"], "evt")
|
||||
self.assertEqual(result["image"], "thumbnail")
|
||||
self.assertEqual(result["description"], "a red car")
|
||||
self.assertIn("start_time_local", result)
|
||||
self.assertTrue(result["_image_url"].startswith("data:image/jpeg;base64,"))
|
||||
self.assertIn("thumbnail", result["_image_text"])
|
||||
|
||||
def test_snapshot_falls_back_to_thumbnail(self):
|
||||
self.make_event("evt", thumbnail=_jpeg_base64(), has_snapshot=False)
|
||||
result = _run(
|
||||
_execute_get_event_image(
|
||||
_request(), {"event_id": "evt", "image": "snapshot"}, ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["image"], "thumbnail")
|
||||
self.assertIn("note", result)
|
||||
|
||||
def test_no_image_available(self):
|
||||
self.make_event("evt", thumbnail="")
|
||||
with patch("frigate.api.chat.get_event_thumbnail_bytes", return_value=None):
|
||||
result = _run(
|
||||
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
|
||||
)
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestGetExportCases(DatabaseTestCase):
|
||||
def test_no_cases(self):
|
||||
result = _execute_get_export_cases(["driveway"])
|
||||
self.assertEqual(result["cases"], [])
|
||||
self.assertIn("message", result)
|
||||
|
||||
def test_counts_only_accessible_exports(self):
|
||||
self.make_case("case_a", name="Break-in")
|
||||
self.make_case("case_b", name="Empty")
|
||||
for idx, camera in enumerate(["driveway", "driveway", "garage"]):
|
||||
Export.create(
|
||||
id=f"exp_{idx}",
|
||||
camera=camera,
|
||||
name=f"Export {idx}",
|
||||
date=datetime.fromtimestamp(1_700_000_000 + idx),
|
||||
video_path=f"/exports/{idx}.mp4",
|
||||
thumb_path=f"/exports/{idx}.jpg",
|
||||
in_progress=False,
|
||||
export_case="case_a",
|
||||
)
|
||||
|
||||
result = _execute_get_export_cases(["driveway"])
|
||||
by_id = {c["id"]: c for c in result["cases"]}
|
||||
self.assertEqual(by_id["case_a"]["export_count"], 2)
|
||||
self.assertEqual(by_id["case_b"]["export_count"], 0)
|
||||
self.assertEqual(by_id["case_a"]["name"], "Break-in")
|
||||
self.assertIn("created_at_local", by_id["case_a"])
|
||||
|
||||
|
||||
class TestCreateExport(DatabaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Recordings.create(
|
||||
id="rec_1",
|
||||
camera="driveway",
|
||||
path="/recordings/rec_1.mp4",
|
||||
start_time=1_700_000_000,
|
||||
end_time=1_700_001_000,
|
||||
duration=1000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _iso(timestamp: int) -> str:
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
def _args(self, **overrides):
|
||||
args = {
|
||||
"camera": "driveway",
|
||||
"start_time": self._iso(1_700_000_100),
|
||||
"end_time": self._iso(1_700_000_200),
|
||||
}
|
||||
args.update(overrides)
|
||||
return args
|
||||
|
||||
def test_requires_camera_and_range(self):
|
||||
result = _run(_execute_create_export(_request(), {}, ["driveway"]))
|
||||
self.assertIn("required", result["error"])
|
||||
|
||||
def test_camera_access_denied(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(), self._args(camera="garage"), ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("access denied", result["error"])
|
||||
|
||||
def test_end_before_start(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
self._args(end_time=self._iso(1_700_000_050)),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("after start_time", result["error"])
|
||||
|
||||
def test_existing_case_requires_admin(self):
|
||||
self.make_case("case_a")
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(role="viewer"),
|
||||
self._args(export_case_id="case_a"),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("admins", result["error"])
|
||||
|
||||
def test_unknown_case(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(), self._args(export_case_id="nope"), ["driveway"]
|
||||
)
|
||||
)
|
||||
self.assertIn("nope", result["error"])
|
||||
|
||||
def test_export_is_queued(self):
|
||||
self.make_case("case_a")
|
||||
with patch("frigate.api.chat.start_export_job") as start:
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
self._args(name="Delivery", export_case_id="case_a"),
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["status"], "queued")
|
||||
self.assertEqual(result["camera"], "driveway")
|
||||
self.assertEqual(result["export_case_id"], "case_a")
|
||||
job = start.call_args.args[1]
|
||||
self.assertEqual(job.camera, "driveway")
|
||||
self.assertEqual(job.request_start_time, 1_700_000_100)
|
||||
self.assertEqual(job.request_end_time, 1_700_000_200)
|
||||
self.assertEqual(job.name, "Delivery")
|
||||
self.assertEqual(job.export_case_id, "case_a")
|
||||
|
||||
def test_no_recordings_in_range(self):
|
||||
result = _run(
|
||||
_execute_create_export(
|
||||
_request(),
|
||||
{
|
||||
"camera": "driveway",
|
||||
"start_time": "2030-01-01T00:00:00",
|
||||
"end_time": "2030-01-01T01:00:00",
|
||||
},
|
||||
["driveway"],
|
||||
)
|
||||
)
|
||||
self.assertIn("No recordings", result["error"])
|
||||
|
||||
def test_queue_full(self):
|
||||
with patch(
|
||||
"frigate.api.chat.start_export_job", side_effect=ExportQueueFullError()
|
||||
):
|
||||
result = _run(
|
||||
_execute_create_export(_request(), self._args(), ["driveway"])
|
||||
)
|
||||
self.assertIn("queue is full", result["error"])
|
||||
|
||||
|
||||
class TestSetCameraStateRoles(unittest.TestCase):
|
||||
def test_non_admin_is_rejected(self):
|
||||
from frigate.api.chat import _execute_set_camera_state
|
||||
|
||||
result = _run(
|
||||
_execute_set_camera_state(
|
||||
_request(role="viewer"),
|
||||
{"camera": "driveway", "feature": "detect", "value": "OFF"},
|
||||
)
|
||||
)
|
||||
self.assertIn("Admin", result["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.app import FrigateApp
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.config import BirdseyeModeEnum
|
||||
|
||||
@@ -510,3 +511,92 @@ class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestNoticeWiring(unittest.TestCase):
|
||||
"""The dispatcher forwards update_notice requests and publishes the list."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.registry = MagicMock()
|
||||
self.registry.active.return_value = [{"id": "retention_unmet"}]
|
||||
config = MagicMock()
|
||||
config.cameras = {}
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
self.dispatcher = Dispatcher(
|
||||
config,
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
{},
|
||||
[],
|
||||
notice_registry=self.registry,
|
||||
)
|
||||
|
||||
def test_registry_listener_is_subscribed(self) -> None:
|
||||
self.registry.subscribe.assert_called_once_with(
|
||||
self.dispatcher._publish_notices
|
||||
)
|
||||
|
||||
def test_raise_request_reaches_registry(self) -> None:
|
||||
self.dispatcher._receive(
|
||||
"update_notice",
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": "front_door",
|
||||
"params": {"restarts": 5},
|
||||
},
|
||||
)
|
||||
|
||||
self.registry.raise_notice.assert_called_once_with(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
def test_resolve_request_reaches_registry(self) -> None:
|
||||
self.dispatcher._receive(
|
||||
"update_notice",
|
||||
{"action": "resolve", "kind": "retention_unmet", "scope": None},
|
||||
)
|
||||
|
||||
self.registry.resolve.assert_called_once_with("retention_unmet", None)
|
||||
|
||||
def test_malformed_request_does_not_raise(self) -> None:
|
||||
self.registry.raise_notice.side_effect = RuntimeError("boom")
|
||||
|
||||
self.dispatcher._receive("update_notice", "not a dict")
|
||||
self.dispatcher._receive(
|
||||
"update_notice", {"action": "raise", "kind": "x", "params": {}}
|
||||
)
|
||||
|
||||
def test_publish_local_skips_mqtt(self) -> None:
|
||||
mqtt = MagicMock(spec=MqttClient)
|
||||
other = MagicMock()
|
||||
self.dispatcher.comms = [mqtt, other]
|
||||
|
||||
self.dispatcher.publish_local("notices", "[]")
|
||||
|
||||
mqtt.publish.assert_not_called()
|
||||
other.publish.assert_called_once_with("notices", "[]", False)
|
||||
|
||||
def test_listener_publishes_active_list(self) -> None:
|
||||
self.dispatcher.publish_local = MagicMock()
|
||||
|
||||
self.dispatcher._publish_notices()
|
||||
|
||||
self.dispatcher.publish_local.assert_called_once_with(
|
||||
"notices", '[{"id": "retention_unmet"}]'
|
||||
)
|
||||
|
||||
def test_snapshot_includes_notices(self) -> None:
|
||||
publisher = MagicMock()
|
||||
self.dispatcher._build_camera_activity_snapshot = MagicMock(
|
||||
return_value=({}, {})
|
||||
)
|
||||
self.dispatcher.web_push_client = None
|
||||
|
||||
self.dispatcher.publish_runtime_snapshot(publisher)
|
||||
|
||||
publisher.assert_any_call("notices", '[{"id": "retention_unmet"}]', False)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the model download failure notice."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.util import downloader
|
||||
from frigate.util.downloader import ModelDownloader
|
||||
|
||||
|
||||
class TestModelDownloadNotice(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.download_path = tempfile.mkdtemp()
|
||||
downloader.last_download_error.clear()
|
||||
|
||||
def _notice_calls(self, requestor: MagicMock) -> list[dict]:
|
||||
return [
|
||||
call.args[1]
|
||||
for call in requestor.send_data.call_args_list
|
||||
if call.args[0] == "update_notice"
|
||||
]
|
||||
|
||||
def _downloader(self, download_func) -> ModelDownloader:
|
||||
with patch("frigate.util.downloader.InterProcessRequestor"):
|
||||
return ModelDownloader(
|
||||
"facedet", self.download_path, ["facedet.onnx"], download_func
|
||||
)
|
||||
|
||||
def test_raising_download_reports_and_reraises(self):
|
||||
def failing(path: str) -> None:
|
||||
raise RuntimeError("HTTP 503 from upstream\nmore detail")
|
||||
|
||||
model_downloader = self._downloader(failing)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
model_downloader._download_models()
|
||||
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "raise")
|
||||
self.assertEqual(notices[0]["kind"], "model_download_failed")
|
||||
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
|
||||
self.assertEqual(
|
||||
notices[0]["params"],
|
||||
{"file": "facedet.onnx", "error": "HTTP 503 from upstream"},
|
||||
)
|
||||
|
||||
def test_swallowing_download_that_leaves_no_file_reports(self):
|
||||
target = os.path.join(self.download_path, "facedet.onnx")
|
||||
|
||||
def swallowing(path: str) -> None:
|
||||
# mirrors the processors: download_from_url fails, they only log
|
||||
with patch(
|
||||
"frigate.util.downloader.requests.get", side_effect=OSError("dns")
|
||||
):
|
||||
try:
|
||||
ModelDownloader.download_from_url("http://x/facedet.onnx", path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
model_downloader = self._downloader(swallowing)
|
||||
model_downloader._download_models()
|
||||
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "raise")
|
||||
self.assertEqual(notices[0]["params"], {"file": "facedet.onnx", "error": "dns"})
|
||||
self.assertFalse(os.path.exists(target))
|
||||
|
||||
def test_success_resolves(self):
|
||||
def succeeding(path: str) -> None:
|
||||
with open(path, "w") as f:
|
||||
f.write("model")
|
||||
|
||||
model_downloader = self._downloader(succeeding)
|
||||
model_downloader._download_models()
|
||||
|
||||
notices = self._notice_calls(model_downloader.requestor)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(notices[0]["action"], "resolve")
|
||||
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
|
||||
|
||||
def test_a_sibling_downloader_only_resolves_its_own_files(self):
|
||||
"""PaddleOCR, facedet and jina each spread one model_name over several
|
||||
downloaders, so a resolve must never reach a sibling's file."""
|
||||
|
||||
def succeeding(path: str) -> None:
|
||||
with open(path, "w") as f:
|
||||
f.write("model")
|
||||
|
||||
with patch("frigate.util.downloader.InterProcessRequestor"):
|
||||
sibling = ModelDownloader(
|
||||
"paddleocr-onnx", self.download_path, ["det.onnx"], succeeding
|
||||
)
|
||||
|
||||
sibling._download_models()
|
||||
|
||||
self.assertEqual(
|
||||
[n["scope"] for n in self._notice_calls(sibling.requestor)],
|
||||
["paddleocr-onnx/det.onnx"],
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Tests for the notice registry and its kind definitions."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.models import Notice, NoticeStats
|
||||
from frigate.notices.registry import DismissResult, NoticeRegistry
|
||||
from frigate.notices.types import (
|
||||
NOTICE_KINDS,
|
||||
NoticeMode,
|
||||
NoticeSeverity,
|
||||
notice_id,
|
||||
)
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
|
||||
class TestNoticeKinds(unittest.TestCase):
|
||||
def test_every_kind_is_keyed_by_its_own_key(self):
|
||||
for key, definition in NOTICE_KINDS.items():
|
||||
self.assertEqual(key, definition.key)
|
||||
|
||||
def test_first_producers_are_defined(self):
|
||||
self.assertEqual(NOTICE_KINDS["ffmpeg_crash_loop"].mode, NoticeMode.state)
|
||||
self.assertEqual(
|
||||
NOTICE_KINDS["ffmpeg_crash_loop"].severity, NoticeSeverity.error
|
||||
)
|
||||
self.assertEqual(NOTICE_KINDS["detector_stuck"].mode, NoticeMode.event)
|
||||
self.assertEqual(
|
||||
NOTICE_KINDS["detector_stuck"].severity, NoticeSeverity.warning
|
||||
)
|
||||
self.assertEqual(NOTICE_KINDS["model_download_failed"].mode, NoticeMode.event)
|
||||
self.assertEqual(NOTICE_KINDS["retention_unmet"].mode, NoticeMode.state)
|
||||
self.assertEqual(NOTICE_KINDS["update_available"].mode, NoticeMode.state)
|
||||
self.assertEqual(NOTICE_KINDS["update_available"].severity, NoticeSeverity.info)
|
||||
|
||||
def test_notice_id_with_and_without_scope(self):
|
||||
self.assertEqual(notice_id("retention_unmet", None), "retention_unmet")
|
||||
self.assertEqual(
|
||||
notice_id("ffmpeg_crash_loop", "front_door"),
|
||||
"ffmpeg_crash_loop:front_door",
|
||||
)
|
||||
|
||||
|
||||
class TestNoticeRegistry(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([Notice, NoticeStats])
|
||||
self.registry = NoticeRegistry()
|
||||
self.listener = MagicMock()
|
||||
self.registry.subscribe(self.listener)
|
||||
|
||||
def tearDown(self):
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
|
||||
try:
|
||||
for file in TEST_DB_CLEANUPS:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_raise_inserts_and_counts_one_occurrence(self):
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["id"], "ffmpeg_crash_loop:front_door")
|
||||
self.assertEqual(active[0]["severity"], "error")
|
||||
self.assertEqual(active[0]["mode"], "state")
|
||||
self.assertEqual(active[0]["category"], "camera")
|
||||
self.assertEqual(active[0]["scope"], "front_door")
|
||||
self.assertEqual(active[0]["params"], {"restarts": 5})
|
||||
self.assertEqual(active[0]["count"], 1)
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_state_re_raise_with_new_params_updates_without_counting(self):
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 6}
|
||||
)
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(active[0]["count"], 1)
|
||||
self.assertEqual(active[0]["params"], {"restarts": 6})
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_state_re_raise_with_same_params_is_a_no_op(self):
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
before = self.registry.active()[0]["last_seen"]
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
|
||||
)
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["last_seen"], before)
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_event_re_raise_counts_and_undismisses(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.assertEqual(self.registry.dismiss("detector_stuck"), DismissResult.ok)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
# no scope here on purpose: a global event id is still valid
|
||||
|
||||
active = self.registry.active()
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["count"], 2)
|
||||
self.assertIsNone(active[0]["dismissed_at"])
|
||||
stats = self.registry.stats()[0]
|
||||
self.assertEqual(stats["occurrences"], 2)
|
||||
self.assertEqual(stats["dismissals"], 1)
|
||||
|
||||
def test_resolve_deletes_and_keeps_stats(self):
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
self.listener.reset_mock()
|
||||
|
||||
self.registry.resolve("retention_unmet")
|
||||
self.registry.resolve("retention_unmet")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
def test_state_notice_cannot_be_dismissed(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.assertEqual(
|
||||
self.registry.dismiss("retention_unmet"), DismissResult.not_dismissable
|
||||
)
|
||||
self.assertEqual(len(self.registry.active()), 1)
|
||||
|
||||
def test_dismiss_unknown_id(self):
|
||||
self.assertEqual(self.registry.dismiss("nope"), DismissResult.not_found)
|
||||
|
||||
def test_dismissed_hidden_unless_requested(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.dismiss("detector_stuck")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
|
||||
|
||||
def test_startup_hides_state_notices_until_confirmed(self):
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["detector_stuck:ov"])
|
||||
|
||||
# the producer confirms the same episode: visible again, no new occurrence
|
||||
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["retention_unmet", "detector_stuck:ov"])
|
||||
self.assertEqual(
|
||||
[s for s in self.registry.stats() if s["kind"] == "retention_unmet"][0][
|
||||
"occurrences"
|
||||
],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unconfirmed_across_two_startups_is_deleted(self):
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
self.registry.mark_state_notices_unconfirmed()
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
|
||||
def test_replay_camera_is_ignored(self):
|
||||
self.registry.raise_notice(
|
||||
"ffmpeg_crash_loop", scope="_replay_front_door", params={}
|
||||
)
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(self.registry.stats(), [])
|
||||
|
||||
def test_unknown_kind_is_dropped(self):
|
||||
self.registry.raise_notice("not_a_kind", params={})
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_active_sorts_by_severity_then_recency(self):
|
||||
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["retention_unmet", "detector_stuck"])
|
||||
|
||||
def test_listener_exception_does_not_propagate(self):
|
||||
self.registry.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
||||
|
||||
self.registry.raise_notice("retention_unmet", params={})
|
||||
|
||||
self.assertEqual(len(self.registry.active()), 1)
|
||||
|
||||
def test_resolve_camera_drops_only_that_camera(self):
|
||||
self.registry.raise_notice("ffmpeg_crash_loop", scope="front_door", params={})
|
||||
self.registry.raise_notice("ffmpeg_crash_loop", scope="garage", params={})
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="front_door", params={}
|
||||
)
|
||||
|
||||
self.registry.resolve_camera("front_door")
|
||||
|
||||
ids = sorted(n["id"] for n in self.registry.active())
|
||||
self.assertEqual(
|
||||
ids, ["ffmpeg_crash_loop:garage", "model_download_failed:front_door"]
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for the daily latest version refresh and the update notice."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.stats import emitter
|
||||
from frigate.stats.util import is_newer_version
|
||||
|
||||
|
||||
class TestIsNewerVersion(unittest.TestCase):
|
||||
def test_newer(self):
|
||||
self.assertTrue(is_newer_version("0.18.1-abcdef", "0.19.0"))
|
||||
self.assertTrue(is_newer_version("0.19.0-abcdef", "0.19.1"))
|
||||
self.assertTrue(is_newer_version("0.19.0-abcdef", "1.0.0"))
|
||||
|
||||
def test_not_newer(self):
|
||||
self.assertFalse(is_newer_version("0.19.0-abcdef", "0.19.0"))
|
||||
self.assertFalse(is_newer_version("0.19.1-abcdef", "0.19.0"))
|
||||
self.assertFalse(is_newer_version("0.19.0-abcdef", "0.19.0-beta2"))
|
||||
|
||||
def test_prerelease_is_behind_its_final_release(self):
|
||||
self.assertTrue(is_newer_version("0.19.0-beta2", "0.19.0"))
|
||||
self.assertTrue(is_newer_version("0.19.0-rc1", "0.19.0"))
|
||||
self.assertTrue(is_newer_version("0.19.0-RC1", "0.19.0"))
|
||||
|
||||
def test_prerelease_of_a_later_line_is_not_behind(self):
|
||||
self.assertFalse(is_newer_version("0.20.0-beta1", "0.19.0"))
|
||||
self.assertFalse(is_newer_version("0.19.0-beta2", "0.19.0-beta2"))
|
||||
|
||||
def test_unparseable_is_never_newer(self):
|
||||
self.assertFalse(is_newer_version("0.19.0-abcdef", "disabled"))
|
||||
self.assertFalse(is_newer_version("0.19.0-abcdef", "unknown"))
|
||||
self.assertFalse(is_newer_version("dev", "0.19.0"))
|
||||
|
||||
|
||||
class TestUpdateNotice(unittest.TestCase):
|
||||
def _emitter(self, latest: str) -> emitter.StatsEmitter:
|
||||
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
stats_emitter.config = MagicMock()
|
||||
stats_emitter.stats_tracking = {"latest_frigate_version": latest}
|
||||
stats_emitter.notice_registry = MagicMock()
|
||||
return stats_emitter
|
||||
|
||||
def test_newer_release_raises(self):
|
||||
stats_emitter = self._emitter("0.19.0")
|
||||
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
|
||||
"update_available", params={"version": "0.19.0"}
|
||||
)
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_current_or_disabled_resolves(self):
|
||||
for latest in ("0.18.1", "disabled"):
|
||||
stats_emitter = self._emitter(latest)
|
||||
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
stats_emitter.notice_registry.raise_notice.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_called_once_with(
|
||||
"update_available"
|
||||
)
|
||||
|
||||
def test_a_failed_lookup_leaves_the_notice_alone(self):
|
||||
stats_emitter = self._emitter("unknown")
|
||||
|
||||
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
|
||||
stats_emitter._check_update_notice()
|
||||
|
||||
stats_emitter.notice_registry.raise_notice.assert_not_called()
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_refresh_updates_tracking_on_a_thread_then_checks(self):
|
||||
stats_emitter = self._emitter("0.18.0")
|
||||
|
||||
with (
|
||||
patch.object(emitter, "get_latest_version", return_value="0.19.0"),
|
||||
patch.object(emitter, "VERSION", "0.18.1-abcdef"),
|
||||
patch.object(emitter.threading, "Thread") as thread,
|
||||
):
|
||||
stats_emitter._refresh_latest_version()
|
||||
thread.return_value.start.assert_called_once()
|
||||
# run the thread body inline
|
||||
thread.call_args.kwargs["target"]()
|
||||
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "0.19.0"
|
||||
)
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once()
|
||||
|
||||
def test_failed_refresh_keeps_the_last_known_version(self):
|
||||
stats_emitter = self._emitter("0.20.0")
|
||||
|
||||
with (
|
||||
patch.object(emitter, "get_latest_version", return_value="unknown"),
|
||||
patch.object(emitter, "VERSION", "0.19.0-abcdef"),
|
||||
patch.object(emitter.threading, "Thread") as thread,
|
||||
):
|
||||
stats_emitter._refresh_latest_version()
|
||||
thread.call_args.kwargs["target"]()
|
||||
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "0.20.0"
|
||||
)
|
||||
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
|
||||
"update_available", params={"version": "0.20.0"}
|
||||
)
|
||||
stats_emitter.notice_registry.resolve.assert_not_called()
|
||||
|
||||
def test_disabled_version_check_still_clears_the_notice(self):
|
||||
stats_emitter = self._emitter("0.20.0")
|
||||
|
||||
with (
|
||||
patch.object(emitter, "get_latest_version", return_value="disabled"),
|
||||
patch.object(emitter, "VERSION", "0.19.0-abcdef"),
|
||||
patch.object(emitter.threading, "Thread") as thread,
|
||||
):
|
||||
stats_emitter._refresh_latest_version()
|
||||
thread.call_args.kwargs["target"]()
|
||||
|
||||
self.assertEqual(
|
||||
stats_emitter.stats_tracking["latest_frigate_version"], "disabled"
|
||||
)
|
||||
stats_emitter.notice_registry.resolve.assert_called_once_with(
|
||||
"update_available"
|
||||
)
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from peewee import DoesNotExist
|
||||
from peewee_migrate import Router
|
||||
@@ -556,6 +556,34 @@ class TestHttp(unittest.TestCase):
|
||||
assert Recordings.get(Recordings.id == rec_k2_id)
|
||||
assert Recordings.get(Recordings.id == rec_k3_id)
|
||||
|
||||
def test_unmet_retention_raises_notice(self):
|
||||
registry = MagicMock()
|
||||
config = FrigateConfig(**self.minimal_config)
|
||||
storage = StorageMaintainer(config, MagicMock(), registry)
|
||||
storage.camera_storage_stats = {
|
||||
"front_door": {"needs_refresh": False, "usage": 10, "bandwidth": 10}
|
||||
}
|
||||
|
||||
with patch.object(storage, "expected_hourly_bandwidth", return_value=100.0):
|
||||
storage.reduce_storage_consumption()
|
||||
|
||||
registry.raise_notice.assert_called_once()
|
||||
kind = registry.raise_notice.call_args.args[0]
|
||||
params = registry.raise_notice.call_args.kwargs["params"]
|
||||
self.assertEqual(kind, "retention_unmet")
|
||||
self.assertEqual(params["needed_mb"], 100.0)
|
||||
self.assertEqual(params["cleared_mb"], 0.0)
|
||||
|
||||
def test_clean_run_resolves_notice(self):
|
||||
registry = MagicMock()
|
||||
config = FrigateConfig(**self.minimal_config)
|
||||
storage = StorageMaintainer(config, MagicMock(), registry)
|
||||
|
||||
with patch.object(storage, "check_storage_needs_cleanup", return_value=False):
|
||||
storage._maintain_once()
|
||||
|
||||
registry.resolve.assert_called_once_with("retention_unmet")
|
||||
|
||||
|
||||
def _insert_mock_event(
|
||||
id: str,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for the detector stuck notice raised by the Frigate watchdog."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.watchdog import FrigateWatchdog
|
||||
|
||||
|
||||
class TestDetectorStuckNotice(unittest.TestCase):
|
||||
def _stuck_detector(self) -> MagicMock:
|
||||
detector = MagicMock()
|
||||
detector.detection_start.value = 1.0
|
||||
detector.detect_process.is_alive.return_value = True
|
||||
return detector
|
||||
|
||||
def test_stuck_restart_raises_notice(self):
|
||||
registry = MagicMock()
|
||||
detector = self._stuck_detector()
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
|
||||
|
||||
with patch("frigate.watchdog.datetime") as mock_datetime:
|
||||
mock_datetime.datetime.now.return_value.timestamp.return_value = 100.0
|
||||
watchdog._check_detectors()
|
||||
|
||||
detector.start_or_restart.assert_called_once()
|
||||
registry.raise_notice.assert_called_once_with(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
def test_healthy_detector_raises_nothing(self):
|
||||
registry = MagicMock()
|
||||
detector = self._stuck_detector()
|
||||
detector.detection_start.value = 0.0
|
||||
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
|
||||
|
||||
watchdog._check_detectors()
|
||||
|
||||
registry.raise_notice.assert_not_called()
|
||||
@@ -7,6 +7,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from frigate.comms.ws import (
|
||||
_WS_BLOCKED_TOPICS,
|
||||
WebSocketClient,
|
||||
_classify_outbound,
|
||||
_collect_zone_names,
|
||||
@@ -106,6 +107,12 @@ class TestClassifyOutbound(unittest.TestCase):
|
||||
def test_birdseye_layout_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
|
||||
|
||||
def test_notices_is_admin_only(self):
|
||||
self.assertEqual(self._classify("notices"), ("admin_only", None))
|
||||
|
||||
def test_update_notice_is_blocked_inbound(self):
|
||||
self.assertIn("update_notice", _WS_BLOCKED_TOPICS)
|
||||
|
||||
# --- Camera-prefixed ---
|
||||
|
||||
def test_camera_state_topic_resolves_to_camera(self):
|
||||
@@ -295,6 +302,19 @@ class TestMaterializeForWs(unittest.TestCase):
|
||||
full = json.dumps({"topic": topic, "payload": payload})
|
||||
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
|
||||
|
||||
def test_admin_only_topic_reaches_admin_not_viewer(self):
|
||||
admin = SimpleNamespace(environ={"HTTP_REMOTE_ROLE": "admin"})
|
||||
viewer = SimpleNamespace(environ={"HTTP_REMOTE_ROLE": "viewer"})
|
||||
scope = ("admin_only", None)
|
||||
|
||||
self.assertEqual(
|
||||
_materialize_for_ws(admin, "notices", "msg", scope, None, self.config),
|
||||
"msg",
|
||||
)
|
||||
self.assertIsNone(
|
||||
_materialize_for_ws(viewer, "notices", "msg", scope, None, self.config)
|
||||
)
|
||||
|
||||
# --- Globals: every authenticated client sees them ---
|
||||
|
||||
def test_globals_reach_admin(self):
|
||||
|
||||
@@ -7,12 +7,22 @@ from pathlib import Path
|
||||
import requests
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_MODEL_STATE
|
||||
from frigate.const import UPDATE_MODEL_STATE, UPDATE_NOTICE
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# target path -> first line of the last download error for it; every existing
|
||||
# download function swallows its exceptions, so this is how the downloader
|
||||
# thread learns why a file is still missing
|
||||
last_download_error: dict[str, str] = {}
|
||||
|
||||
|
||||
def _first_line(error: BaseException) -> str:
|
||||
text = str(error).strip().splitlines()
|
||||
return (text[0] if text else type(error).__name__)[:200]
|
||||
|
||||
|
||||
class ModelDownloader:
|
||||
def __init__(
|
||||
@@ -48,6 +58,33 @@ class ModelDownloader:
|
||||
)
|
||||
self.download_thread.start()
|
||||
|
||||
def _notice_scope(self, file_name: str) -> str:
|
||||
# per file: several loaders share one model_name with disjoint files,
|
||||
# so a model wide scope lets one of them clear another's notice
|
||||
return f"{self.model_name}/{file_name}"
|
||||
|
||||
def _report_failure(self, file_name: str, error: str) -> None:
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "model_download_failed",
|
||||
"scope": self._notice_scope(file_name),
|
||||
"params": {"file": file_name, "error": error},
|
||||
},
|
||||
)
|
||||
|
||||
def _resolve_failure(self, file_name: str) -> None:
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "resolve",
|
||||
"kind": "model_download_failed",
|
||||
"scope": self._notice_scope(file_name),
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
|
||||
def _download_models(self):
|
||||
for file_name in self.file_names:
|
||||
path = os.path.join(self.download_path, file_name)
|
||||
@@ -57,8 +94,20 @@ class ModelDownloader:
|
||||
if not os.path.exists(path):
|
||||
with lock:
|
||||
if not os.path.exists(path):
|
||||
self.download_func(path)
|
||||
try:
|
||||
self.download_func(path)
|
||||
except Exception as e:
|
||||
self._report_failure(file_name, _first_line(e))
|
||||
raise
|
||||
|
||||
if not os.path.exists(path):
|
||||
self._report_failure(
|
||||
file_name,
|
||||
last_download_error.pop(path, "download failed"),
|
||||
)
|
||||
continue
|
||||
|
||||
self._resolve_failure(file_name)
|
||||
self.requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
@@ -93,6 +142,7 @@ class ModelDownloader:
|
||||
temporary_filename.rename(save_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading model: {str(e)}")
|
||||
last_download_error[save_path] = _first_line(e)
|
||||
raise
|
||||
|
||||
if not silent:
|
||||
|
||||
@@ -29,6 +29,7 @@ from frigate.const import (
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
STREAM_TYPE_TO_ROLE,
|
||||
UPDATE_NOTICE,
|
||||
)
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
@@ -43,6 +44,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_GRACE_SECONDS = 90
|
||||
|
||||
# a camera whose ffmpeg restarts this many times in the trailing hour is in a
|
||||
# crash loop; it recovers once the capture thread stays up this long
|
||||
CRASH_LOOP_RESTARTS = 5
|
||||
CRASH_LOOP_RECOVERY_S = 600
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -140,6 +146,8 @@ class CameraWatchdog(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.sleeptime = self.config.ffmpeg.retry_interval
|
||||
self.reconnect_timestamps = deque()
|
||||
self.crash_loop_raised = False
|
||||
self._crash_loop_sent_restarts = 0
|
||||
self.stalls = stalls
|
||||
self.reconnects = reconnects
|
||||
self.detection_frame = detection_frame
|
||||
@@ -189,6 +197,53 @@ class CameraWatchdog(threading.Thread):
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_status_update_time: float = 0.0
|
||||
|
||||
def _update_crash_loop_notice(self, now: float) -> None:
|
||||
"""Raise the crash loop notice as restarts pile up, resolve once stable.
|
||||
|
||||
Called on every 1 second tick, so raises are sent only when the restart
|
||||
count changes; the registry treats repeats of a state notice as updates.
|
||||
"""
|
||||
restarts = len(self.reconnect_timestamps)
|
||||
alive = self.capture_thread is not None and self.capture_thread.is_alive()
|
||||
|
||||
if not alive and restarts >= CRASH_LOOP_RESTARTS:
|
||||
if self.crash_loop_raised and restarts == self._crash_loop_sent_restarts:
|
||||
return
|
||||
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "raise",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": self.config.name,
|
||||
"params": {"restarts": restarts},
|
||||
},
|
||||
)
|
||||
self.crash_loop_raised = True
|
||||
self._crash_loop_sent_restarts = restarts
|
||||
elif (
|
||||
self.crash_loop_raised
|
||||
and alive
|
||||
and (
|
||||
restarts == 0
|
||||
or now - self.reconnect_timestamps[-1] >= CRASH_LOOP_RECOVERY_S
|
||||
)
|
||||
):
|
||||
self._resolve_crash_loop()
|
||||
|
||||
def _resolve_crash_loop(self) -> None:
|
||||
self.requestor.send_data(
|
||||
UPDATE_NOTICE,
|
||||
{
|
||||
"action": "resolve",
|
||||
"kind": "ffmpeg_crash_loop",
|
||||
"scope": self.config.name,
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
self.crash_loop_raised = False
|
||||
self._crash_loop_sent_restarts = 0
|
||||
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
@@ -379,6 +434,9 @@ class CameraWatchdog(threading.Thread):
|
||||
# cameras without a sub stream never get a record_sub topic
|
||||
if self.config.record.sub.enabled:
|
||||
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
|
||||
|
||||
if self.crash_loop_raised:
|
||||
self._resolve_crash_loop()
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -456,6 +514,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.logger.error(
|
||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||
)
|
||||
self._update_crash_loop_notice(now)
|
||||
if can_restart:
|
||||
self.reset_capture_thread(terminate=False)
|
||||
last_restart_time = now
|
||||
@@ -485,6 +544,7 @@ class CameraWatchdog(threading.Thread):
|
||||
# process is running normally
|
||||
self._send_detect_status("online", now)
|
||||
self.fps_overflow_count = 0
|
||||
self._update_crash_loop_notice(now)
|
||||
|
||||
for p in self.ffmpeg_other_processes:
|
||||
poll = p["process"].poll()
|
||||
|
||||
+28
-18
@@ -7,6 +7,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.services import restart_frigate
|
||||
@@ -44,10 +45,12 @@ class FrigateWatchdog(threading.Thread):
|
||||
self,
|
||||
detectors: dict[str, ObjectDetectProcess],
|
||||
stop_event: MpEvent,
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
):
|
||||
super().__init__(name="frigate_watchdog")
|
||||
self.detectors = detectors
|
||||
self.stop_event = stop_event
|
||||
self.notice_registry = notice_registry
|
||||
self._monitored: list[MonitoredProcess] = []
|
||||
|
||||
def register(
|
||||
@@ -111,27 +114,34 @@ class FrigateWatchdog(threading.Thread):
|
||||
except Exception:
|
||||
logger.exception("Failed to restart %s", entry.name)
|
||||
|
||||
def _check_detectors(self) -> None:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for name, detector in self.detectors.items():
|
||||
detection_start = detector.detection_start.value # type: ignore[attr-defined]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
if detection_start > 0.0 and now - detection_start > 10:
|
||||
logger.info(
|
||||
"Detection appears to be stuck. Restarting detection process..."
|
||||
)
|
||||
detector.start_or_restart()
|
||||
|
||||
if self.notice_registry is not None:
|
||||
self.notice_registry.raise_notice(
|
||||
"detector_stuck", scope=name, params={"detector": name}
|
||||
)
|
||||
elif (
|
||||
detector.detect_process is not None
|
||||
and not detector.detect_process.is_alive()
|
||||
):
|
||||
logger.info("Detection appears to have stopped. Exiting Frigate...")
|
||||
restart_frigate()
|
||||
|
||||
def run(self) -> None:
|
||||
time.sleep(10)
|
||||
while not self.stop_event.wait(10):
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
# check the detection processes
|
||||
for detector in self.detectors.values():
|
||||
detection_start = detector.detection_start.value # type: ignore[attr-defined]
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
if detection_start > 0.0 and now - detection_start > 10:
|
||||
logger.info(
|
||||
"Detection appears to be stuck. Restarting detection process..."
|
||||
)
|
||||
detector.start_or_restart()
|
||||
elif (
|
||||
detector.detect_process is not None
|
||||
and not detector.detect_process.is_alive()
|
||||
):
|
||||
logger.info("Detection appears to have stopped. Exiting Frigate...")
|
||||
restart_frigate()
|
||||
self._check_detectors()
|
||||
|
||||
for entry in self._monitored:
|
||||
self._check_process(entry)
|
||||
|
||||
@@ -59,6 +59,7 @@ from frigate.api import (
|
||||
hardware,
|
||||
media,
|
||||
motion_search,
|
||||
notices,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
@@ -154,6 +155,7 @@ def build_app() -> FastAPI:
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
notices.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Peewee migrations -- 038_create_notices.py.
|
||||
|
||||
Creates the two notice tables: active notices, and per-kind lifetime counts.
|
||||
"""
|
||||
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "notice" ('
|
||||
'"id" VARCHAR(150) NOT NULL PRIMARY KEY, '
|
||||
'"kind" VARCHAR(50) NOT NULL, '
|
||||
'"scope" VARCHAR(100), '
|
||||
'"params" TEXT NOT NULL, '
|
||||
'"first_seen" DATETIME NOT NULL, '
|
||||
'"last_seen" DATETIME NOT NULL, '
|
||||
'"count" INTEGER NOT NULL, '
|
||||
'"dismissed_at" DATETIME)'
|
||||
)
|
||||
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "noticestats" ('
|
||||
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
|
||||
'"occurrences" INTEGER NOT NULL, '
|
||||
'"dismissals" INTEGER NOT NULL, '
|
||||
'"first_seen" DATETIME NOT NULL, '
|
||||
'"last_seen" DATETIME NOT NULL, '
|
||||
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
|
||||
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql('DROP TABLE IF EXISTS "notice"')
|
||||
migrator.sql('DROP TABLE IF EXISTS "noticestats"')
|
||||
@@ -19,7 +19,7 @@ export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
export function deepMerge<T extends Record<string, unknown>>(
|
||||
base: T,
|
||||
overrides?: DeepPartial<T>,
|
||||
): T {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* FrigateStats factory for E2E tests.
|
||||
*/
|
||||
|
||||
import type { DeepPartial } from "./config";
|
||||
import { deepMerge, type DeepPartial } from "./config";
|
||||
|
||||
function cameraStats(_name: string) {
|
||||
return {
|
||||
@@ -72,5 +72,5 @@ export function statsFactory(
|
||||
overrides?: DeepPartial<typeof BASE_STATS>,
|
||||
): typeof BASE_STATS {
|
||||
if (!overrides) return BASE_STATS;
|
||||
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
|
||||
return deepMerge(BASE_STATS, overrides) as typeof BASE_STATS;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface ApiMockOverrides {
|
||||
available?: { key: string; presets: Record<string, string> }[];
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
noticeStats?: unknown[];
|
||||
}
|
||||
|
||||
export class ApiMocker {
|
||||
@@ -201,6 +203,15 @@ export class ApiMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// Notices. The stats route is registered after the list route so it wins
|
||||
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
||||
await this.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: overrides?.notices ?? [] }),
|
||||
);
|
||||
await this.page.route("**/api/notices/stats", (route) =>
|
||||
route.fulfill({ json: overrides?.noticeStats ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
// tests assert on the intercepted request body instead of a response.
|
||||
await this.page.route("**/api/users**", (route) =>
|
||||
|
||||
@@ -130,8 +130,8 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||
|
||||
const TARGETS = [
|
||||
{ label: "Settings", url: /\/settings/ },
|
||||
{ label: "System metrics", url: /\/system/ },
|
||||
{ label: "System logs", url: /\/logs/ },
|
||||
{ label: "Health and Metrics", url: /\/system/ },
|
||||
{ label: "Logs", url: /\/logs/ },
|
||||
{ label: "Configuration Editor", url: /\/config/ },
|
||||
];
|
||||
|
||||
@@ -142,7 +142,7 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
||||
.first();
|
||||
await gear.click();
|
||||
await frigateApp.page.getByLabel(target.label).click();
|
||||
await frigateApp.page.getByLabel(target.label, { exact: true }).click();
|
||||
await expect(frigateApp.page).toHaveURL(target.url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Health tab tests -- MEDIUM tier.
|
||||
*
|
||||
* Default tab, notice list rendering, dismiss, empty state, update notice.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
const STATE_NOTICE = {
|
||||
id: "ffmpeg_crash_loop:front_door",
|
||||
kind: "ffmpeg_crash_loop",
|
||||
mode: "state",
|
||||
severity: "error",
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { restarts: 6 },
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
id: "detector_stuck",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: null,
|
||||
params: { detector: "ov" },
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
test.describe("System — Health tab @medium", () => {
|
||||
test("Health is the default tab and lists notices by severity", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [STATE_NOTICE, EVENT_NOTICE],
|
||||
noticeStats: [
|
||||
{
|
||||
kind: "ffmpeg_crash_loop",
|
||||
occurrences: 14,
|
||||
dismissals: 0,
|
||||
first_seen: NOW - 86400 * 20,
|
||||
last_seen: NOW,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.nth(0)).toHaveAttribute("data-severity", "error");
|
||||
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
|
||||
await expect(rows.nth(0)).toContainText("14 times since");
|
||||
// the default fixture's cpu detector is slow (75.5 ms); PR 2 merges live
|
||||
// problems into this list, PR 1 does not, so no extra row here
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toHaveCount(0);
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
rows.nth(1).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("dismiss posts and removes the row", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ notices: [EVENT_NOTICE] });
|
||||
|
||||
// the list shrinks after the dismiss so the refetch shows the row gone
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/detector_stuck/dismiss",
|
||||
(route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/notices/detector_stuck/dismiss") &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(frigateApp.page.getByText("No notices")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults();
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(frigateApp.page.getByText("No notices")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("update notice renders as info with a release link", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [
|
||||
{
|
||||
id: "update_available",
|
||||
kind: "update_available",
|
||||
mode: "state",
|
||||
severity: "info",
|
||||
category: "system",
|
||||
scope: null,
|
||||
params: { version: "0.19.0" },
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:update_available",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
await expect(row).toContainText("Frigate 0.19.0 is available");
|
||||
await expect(row.getByRole("link", { name: "Open link" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
);
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("System — Health tab mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("notices render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ notices: [STATE_NOTICE] });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-notice:ffmpeg_crash_loop:front_door",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -168,9 +168,9 @@
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
"systemMetrics": "System metrics",
|
||||
"systemMetrics": "Health and Metrics",
|
||||
"configuration": "Configuration",
|
||||
"systemLogs": "System logs",
|
||||
"systemLogs": "Logs",
|
||||
"profiles": "Profiles",
|
||||
"settings": "Settings",
|
||||
"configurationEditor": "Configuration Editor",
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
"auto_scroll": {
|
||||
"title": "Auto-scroll",
|
||||
"desc": "Follow new messages as they arrive."
|
||||
},
|
||||
"always_allow": {
|
||||
"title": "Always allowed actions",
|
||||
"desc": "Actions the assistant may run without asking first.",
|
||||
"none": "None",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
@@ -68,5 +74,15 @@
|
||||
},
|
||||
"thinking": {
|
||||
"toggle": "Toggle thinking"
|
||||
},
|
||||
"approval": {
|
||||
"title": "Approve {{tool}}?",
|
||||
"desc": "This action changes something in Frigate. Review the details before approving.",
|
||||
"approve": "Approve",
|
||||
"always_allow": "Always allow",
|
||||
"reject": "Reject",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"placeholder": "Approve or reject the pending action to continue"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,31 @@
|
||||
"go2rtc": "Go2RTC Logs - Frigate",
|
||||
"nginx": "Nginx Logs - Frigate",
|
||||
"websocket": "Messages Logs - Frigate"
|
||||
}
|
||||
},
|
||||
"health": "Health - Frigate"
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
"title": "Notices",
|
||||
"empty": "No notices",
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open settings",
|
||||
"openLink": "Open link",
|
||||
"since": "Since {{time}}",
|
||||
"sinceWithCount": "Since {{time}} · {{times}} times since {{firstSeen}}",
|
||||
"firstSeen": "First seen {{time}} · {{times}} times",
|
||||
"kinds": {
|
||||
"ffmpeg_crash_loop": "ffmpeg has crashed {{restarts}} times in the last hour",
|
||||
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
|
||||
"model_download_failed": "Downloading {{file}} failed: {{error}}",
|
||||
"retention_unmet": "Recordings were deleted before their retention period to free space ({{cleared_mb}} of {{needed_mb}} MB needed)",
|
||||
"update_available": "Frigate {{version}} is available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"websocket": {
|
||||
"label": "Messages",
|
||||
|
||||
@@ -26,6 +26,9 @@ type ChatComposerProps = {
|
||||
|
||||
isLoading?: boolean;
|
||||
onStop?: () => void;
|
||||
/** Blocks input without showing the stop button, e.g. while a tool call
|
||||
* is waiting for the user's approval. */
|
||||
disabled?: boolean;
|
||||
|
||||
attachedEventId?: string | null;
|
||||
onClearAttachment?: () => void;
|
||||
@@ -45,6 +48,7 @@ export function ChatComposer({
|
||||
setThinkingEnabled,
|
||||
isLoading = false,
|
||||
onStop,
|
||||
disabled = false,
|
||||
attachedEventId,
|
||||
onClearAttachment,
|
||||
onAttach,
|
||||
@@ -62,6 +66,7 @@ export function ChatComposer({
|
||||
|
||||
const showPaperclip = !!onAttach;
|
||||
const showStop = isLoading && !!onStop;
|
||||
const inputBlocked = isLoading || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
@@ -77,7 +82,7 @@ export function ChatComposer({
|
||||
{attachedEventId && (
|
||||
<ChatQuickReplies
|
||||
onSend={(text) => sendMessage(text)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
@@ -85,7 +90,7 @@ export function ChatComposer({
|
||||
<ChatPaperclipButton
|
||||
recentEventIds={recentEventIds ?? []}
|
||||
onAttach={onAttach!}
|
||||
disabled={isLoading || attachedEventId != null}
|
||||
disabled={inputBlocked || attachedEventId != null}
|
||||
/>
|
||||
)}
|
||||
{supportsThinking && (
|
||||
@@ -103,7 +108,7 @@ export function ChatComposer({
|
||||
!thinkingEnabled && "text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => setThinkingEnabled(!thinkingEnabled)}
|
||||
disabled={isLoading}
|
||||
disabled={inputBlocked}
|
||||
>
|
||||
<LuBrain className="size-4" />
|
||||
</Button>
|
||||
@@ -122,6 +127,7 @@ export function ChatComposer({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showStop ? (
|
||||
<Button
|
||||
@@ -135,7 +141,7 @@ export function ChatComposer({
|
||||
<Button
|
||||
variant="select"
|
||||
className="size-10 shrink-0 rounded-full"
|
||||
disabled={!input.trim() || isLoading}
|
||||
disabled={!input.trim() || inputBlocked}
|
||||
onClick={() => sendMessage()}
|
||||
>
|
||||
<FaArrowUpLong className="size-4" />
|
||||
|
||||
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShowStatsMode } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ChatSettingsProps = {
|
||||
showStats: ShowStatsMode;
|
||||
setShowStats: (mode: ShowStatsMode) => void;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (enabled: boolean) => void;
|
||||
alwaysAllowTools: string[];
|
||||
clearAlwaysAllowTools: () => void;
|
||||
};
|
||||
|
||||
export default function ChatSettings({
|
||||
@@ -29,6 +32,8 @@ export default function ChatSettings({
|
||||
setShowStats,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
alwaysAllowTools,
|
||||
clearAlwaysAllowTools,
|
||||
}: ChatSettingsProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -90,6 +95,40 @@ export default function ChatSettings({
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div>{t("settings.always_allow.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.desc")}
|
||||
</div>
|
||||
</div>
|
||||
{alwaysAllowTools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alwaysAllowTools.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-md bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
||||
>
|
||||
{formatToolName(name)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.always_allow.none")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={alwaysAllowTools.length === 0}
|
||||
onClick={clearAlwaysAllowTools}
|
||||
>
|
||||
{t("settings.always_allow.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShieldAlert, LuCheck, LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
import type { PendingToolCall, ToolDecision } from "@/types/chat";
|
||||
|
||||
type ToolApprovalCardProps = {
|
||||
toolCall: PendingToolCall;
|
||||
decision?: ToolDecision;
|
||||
onApprove: (id: string) => void;
|
||||
onAlwaysAllow: (id: string, name: string) => void;
|
||||
onReject: (id: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt shown when the assistant wants to run a state-changing tool.
|
||||
* Renders the call's arguments and approve / always allow / reject actions;
|
||||
* once decided it collapses into a status line.
|
||||
*/
|
||||
export function ToolApprovalCard({
|
||||
toolCall,
|
||||
decision,
|
||||
onApprove,
|
||||
onAlwaysAllow,
|
||||
onReject,
|
||||
}: ToolApprovalCardProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const displayName = formatToolName(toolCall.name);
|
||||
const hasArguments = Object.keys(toolCall.arguments ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-[85%] flex-col gap-3 self-start rounded-xl border border-border bg-muted px-4 py-3"
|
||||
role="group"
|
||||
aria-label={t("approval.title", { tool: displayName })}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<LuShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("approval.title", { tool: displayName })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("approval.desc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasArguments && (
|
||||
<pre className="scrollbar-container max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-background/50 p-2 text-[10px]">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{decision ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-medium",
|
||||
decision === "approve" ? "text-success" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{decision === "approve" ? (
|
||||
<LuCheck className="size-3.5" />
|
||||
) : (
|
||||
<LuX className="size-3.5" />
|
||||
)}
|
||||
{decision === "approve"
|
||||
? t("approval.approved")
|
||||
: t("approval.rejected")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onApprove(toolCall.id)}
|
||||
>
|
||||
{t("approval.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="select"
|
||||
onClick={() => onAlwaysAllow(toolCall.id, toolCall.name)}
|
||||
>
|
||||
{t("approval.always_allow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(toolCall.id)}
|
||||
>
|
||||
{t("approval.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,19 +7,12 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { LuChevronsUpDown } from "react-icons/lu";
|
||||
import type { ToolCall } from "@/types/chat";
|
||||
import { formatToolName } from "@/utils/chatUtil";
|
||||
|
||||
type ToolCallsGroupProps = {
|
||||
toolCalls: ToolCall[];
|
||||
};
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, ToolCall[]>();
|
||||
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
|
||||
function ToolCallRow({ name, calls }: ToolCallRowProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const displayName = normalizeName(name);
|
||||
const displayName = formatToolName(name);
|
||||
const label =
|
||||
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import {
|
||||
LuExternalLink,
|
||||
LuInfo,
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
|
||||
type HealthProblemRowProps = {
|
||||
problem: HealthProblem;
|
||||
};
|
||||
|
||||
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
|
||||
data-testid={`health-problem-${problem.id}`}
|
||||
data-severity={problem.severity}
|
||||
>
|
||||
<div className="mt-0.5 flex shrink-0">
|
||||
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
|
||||
{problem.severity === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
||||
)}
|
||||
{problem.severity === "info" && (
|
||||
<LuInfo className="size-4 text-selected" />
|
||||
)}
|
||||
</div>
|
||||
{problem.scope && (
|
||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
|
||||
{problem.scopeIsCamera ? (
|
||||
<CameraNameLabel camera={problem.scope} />
|
||||
) : (
|
||||
problem.scope
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{problem.text}</div>
|
||||
{problem.meta && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{problem.meta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
||||
{problem.onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
>
|
||||
{t("health.notices.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
{problem.link && (
|
||||
<Link
|
||||
to={problem.link}
|
||||
aria-label={t("health.notices.openSettings")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuSlidersHorizontal className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
{problem.externalLink && (
|
||||
<a
|
||||
href={problem.externalLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("health.notices.openLink")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuExternalLink className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import useSWR from "swr";
|
||||
import HealthProblemRow from "@/components/health/HealthProblemRow";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useNotices } from "@/hooks/use-notices";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { releaseUrl } from "@/utils/versionUtil";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
|
||||
|
||||
const SETTINGS_LINK_BY_KIND: Partial<
|
||||
Record<NoticeKind, (scope: string | null) => string>
|
||||
> = {
|
||||
ffmpeg_crash_loop: (scope) => `/settings?page=cameraFfmpeg&camera=${scope}`,
|
||||
retention_unmet: () => "/system#storage",
|
||||
detector_stuck: () => "/system#general",
|
||||
};
|
||||
|
||||
const EXTERNAL_LINK_BY_KIND: Partial<
|
||||
Record<NoticeKind, (params: Notice["params"]) => string | undefined>
|
||||
> = {
|
||||
update_available: (params) =>
|
||||
typeof params.version === "string" ? releaseUrl(params.version) : undefined,
|
||||
};
|
||||
|
||||
function useNoticeProblems(
|
||||
notices: Notice[] | undefined,
|
||||
statsByKind: Partial<Record<NoticeKind, NoticeStats>>,
|
||||
dismiss: (id: string) => Promise<void>,
|
||||
): HealthProblem[] {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useTimezone(config);
|
||||
const locale = useDateLocale();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!notices) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number, date_format: string) =>
|
||||
formatUnixTimestampToDateTime(timestamp, {
|
||||
timezone,
|
||||
date_format,
|
||||
locale,
|
||||
});
|
||||
|
||||
return notices.map((notice) => {
|
||||
const stats = statsByKind[notice.kind];
|
||||
const seen = formatTime(notice.first_seen, "MMM d, h:mm a");
|
||||
let meta: string;
|
||||
|
||||
if (notice.mode === "event") {
|
||||
meta = t("health.notices.firstSeen", {
|
||||
time: seen,
|
||||
times: notice.count,
|
||||
});
|
||||
} else if (stats && stats.occurrences > 1) {
|
||||
meta = t("health.notices.sinceWithCount", {
|
||||
time: seen,
|
||||
times: stats.occurrences,
|
||||
firstSeen: formatTime(stats.first_seen, "MMM d"),
|
||||
});
|
||||
} else {
|
||||
meta = t("health.notices.since", { time: seen });
|
||||
}
|
||||
|
||||
const link = SETTINGS_LINK_BY_KIND[notice.kind];
|
||||
const external = EXTERNAL_LINK_BY_KIND[notice.kind];
|
||||
|
||||
return {
|
||||
id: `notice:${notice.id}`,
|
||||
severity: notice.severity,
|
||||
scope: notice.scope ?? undefined,
|
||||
scopeIsCamera: notice.category === "camera",
|
||||
// replace keeps backend params out of i18next's own option names
|
||||
text: t(`health.notices.kinds.${notice.kind}`, {
|
||||
replace: notice.params,
|
||||
}),
|
||||
meta,
|
||||
link: link ? link(notice.scope) : undefined,
|
||||
externalLink: external ? external(notice.params) : undefined,
|
||||
onDismiss:
|
||||
notice.mode === "event" ? () => dismiss(notice.id) : undefined,
|
||||
};
|
||||
});
|
||||
}, [notices, statsByKind, dismiss, t, timezone, locale]);
|
||||
}
|
||||
|
||||
export default function NoticesPane() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { notices, statsByKind, dismiss } = useNotices();
|
||||
const problems = useNoticeProblems(notices, statsByKind, dismiss);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-md font-medium text-primary-variant">
|
||||
{t("health.notices.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{notices === undefined ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : problems.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span>{t("health.notices.empty")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{problems.map((problem) => (
|
||||
<HealthProblemRow key={problem.id} problem={problem} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -326,7 +326,7 @@ export default function GeneralSettings({
|
||||
<DropdownMenuLabel>{t("menu.system")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<Link to="/system">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
import { useWs } from "@/api/ws";
|
||||
import type { Notice, NoticeStats } from "@/types/notice";
|
||||
|
||||
/**
|
||||
* Active notices: a REST snapshot, replaced by every `notices` websocket
|
||||
* payload after that. Stats are fetched separately and only change on
|
||||
* dismiss or a new occurrence, so they revalidate after each dismiss.
|
||||
*/
|
||||
export function useNotices() {
|
||||
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: stats, mutate: mutateStats } = useSWR<NoticeStats[]>(
|
||||
"notices/stats",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("notices", "");
|
||||
|
||||
const live = useMemo(
|
||||
() =>
|
||||
payload && typeof payload === "string"
|
||||
? (JSON.parse(payload) as Notice[])
|
||||
: undefined,
|
||||
[payload],
|
||||
);
|
||||
|
||||
// once a websocket frame has arrived it is the source of truth; a dismiss
|
||||
// still shows up because the registry publishes a new frame after it
|
||||
const notices = live ?? initial;
|
||||
|
||||
const statsByKind = useMemo(() => {
|
||||
const byKind: Partial<Record<Notice["kind"], NoticeStats>> = {};
|
||||
(stats ?? []).forEach((entry) => {
|
||||
byKind[entry.kind] = entry;
|
||||
});
|
||||
return byKind;
|
||||
}, [stats]);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
mutate();
|
||||
mutateStats();
|
||||
},
|
||||
[mutate, mutateStats],
|
||||
);
|
||||
|
||||
return { notices, statsByKind, dismiss };
|
||||
}
|
||||
+149
-15
@@ -8,6 +8,7 @@ import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow
|
||||
import { MessageBubble } from "@/components/chat/ChatMessage";
|
||||
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
|
||||
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
|
||||
import { ToolApprovalCard } from "@/components/chat/ToolApprovalCard";
|
||||
import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatComposer } from "@/components/chat/ChatComposer";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
GenAIModelsResponse,
|
||||
PendingToolCall,
|
||||
ShowStatsMode,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
getEventIdsFromToolCalls,
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
prependAttachment,
|
||||
streamChatCompletion,
|
||||
@@ -40,6 +43,13 @@ const hasText = (content: unknown): content is string =>
|
||||
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
|
||||
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
|
||||
|
||||
// Stable default so usePersistence does not reload on every render.
|
||||
const NO_TOOLS: string[] = [];
|
||||
|
||||
type ResumeOptions = {
|
||||
toolDecisions: Record<string, ToolDecision>;
|
||||
};
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -48,6 +58,21 @@ export default function ChatPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||
// Write tool calls the backend paused on, plus the user's decisions so far.
|
||||
const [pendingApprovals, setPendingApprovals] = useState<
|
||||
PendingToolCall[] | null
|
||||
>(null);
|
||||
const [approvalDecisions, setApprovalDecisions] = useState<
|
||||
Record<string, ToolDecision>
|
||||
>({});
|
||||
// Tools the user chose to always allow. Kept only in this browser; the
|
||||
// backend never sees the list, the client just answers for them.
|
||||
const [alwaysAllowTools, setAlwaysAllowTools] = usePersistence<string[]>(
|
||||
"chat-always-allow-tools",
|
||||
NO_TOOLS,
|
||||
);
|
||||
const alwaysAllowRef = useRef<string[]>(NO_TOOLS);
|
||||
alwaysAllowRef.current = alwaysAllowTools ?? NO_TOOLS;
|
||||
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
||||
"chat-show-stats",
|
||||
"while_generating",
|
||||
@@ -62,6 +87,7 @@ export default function ChatPage() {
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -92,14 +118,27 @@ export default function ChatPage() {
|
||||
}, [messages, streaming, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
if (isLoading) return;
|
||||
async function submit(
|
||||
messagesToSend: ChatMessage[],
|
||||
resume?: ResumeOptions,
|
||||
) {
|
||||
if (loadingRef.current) return;
|
||||
const last = messagesToSend[messagesToSend.length - 1];
|
||||
if (!last || last.role !== "user" || !hasText(last.content)) return;
|
||||
if (!last) return;
|
||||
// A normal turn ends with the user's message; a resume after an
|
||||
// approval pause ends with the assistant's pending tool calls.
|
||||
if (resume) {
|
||||
if (last.role !== "assistant" || !last.tool_calls?.length) return;
|
||||
} else if (last.role !== "user" || !hasText(last.content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
setMessages(messagesToSend);
|
||||
setStreaming({ content: "", reasoning: "", chain: [] });
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
const baseURL = axios.defaults.baseURL ?? "";
|
||||
@@ -116,6 +155,7 @@ export default function ChatPage() {
|
||||
let stats: ChatStats | undefined;
|
||||
let reasoning = "";
|
||||
let hadError = false;
|
||||
let approvals: PendingToolCall[] | null = null;
|
||||
|
||||
await streamChatCompletion(
|
||||
url,
|
||||
@@ -138,32 +178,99 @@ export default function ChatPage() {
|
||||
stats = s;
|
||||
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
|
||||
},
|
||||
onApprovalRequired: (toolCalls) => {
|
||||
approvals = toolCalls;
|
||||
},
|
||||
onError: (message) => {
|
||||
hadError = true;
|
||||
setError(message);
|
||||
},
|
||||
onDone: () => {
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
const lastMsg = chain[chain.length - 1];
|
||||
if (!hadError && lastMsg?.role === "assistant") {
|
||||
setMessages(
|
||||
chain.map((m, i) =>
|
||||
i === chain.length - 1
|
||||
? { ...m, reasoning: reasoning || undefined, stats }
|
||||
: m,
|
||||
),
|
||||
const committed = chain.map((m, i) =>
|
||||
i === chain.length - 1
|
||||
? { ...m, reasoning: reasoning || undefined, stats }
|
||||
: m,
|
||||
);
|
||||
setMessages(committed);
|
||||
if (approvals?.length) {
|
||||
// Calls to always-allowed tools are answered here without
|
||||
// prompting; anything else waits for the user.
|
||||
const allowed = alwaysAllowRef.current;
|
||||
const auto: Record<string, ToolDecision> = {};
|
||||
for (const tc of approvals) {
|
||||
if (allowed.includes(tc.name)) auto[tc.id] = "approve";
|
||||
}
|
||||
if (Object.keys(auto).length === approvals.length) {
|
||||
submit(committed, { toolDecisions: auto });
|
||||
} else {
|
||||
setApprovalDecisions(auto);
|
||||
setPendingApprovals(approvals);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultErrorMessage: t("error"),
|
||||
},
|
||||
controller.signal,
|
||||
supportsThinking ? { enableThinking: !!thinkingEnabled } : {},
|
||||
{
|
||||
...(supportsThinking ? { enableThinking: !!thinkingEnabled } : {}),
|
||||
toolDecisions: resume?.toolDecisions,
|
||||
},
|
||||
);
|
||||
},
|
||||
[isLoading, supportsThinking, t, thinkingEnabled],
|
||||
[supportsThinking, t, thinkingEnabled],
|
||||
);
|
||||
|
||||
// Resume the paused turn once every pending call has a decision.
|
||||
const applyDecisions = useCallback(
|
||||
(next: Record<string, ToolDecision>) => {
|
||||
setApprovalDecisions(next);
|
||||
if (!pendingApprovals) return;
|
||||
if (!pendingApprovals.every((tc) => next[tc.id] !== undefined)) return;
|
||||
submitConversation(messages, { toolDecisions: next });
|
||||
},
|
||||
[messages, pendingApprovals, submitConversation],
|
||||
);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "approve" }),
|
||||
[applyDecisions, approvalDecisions],
|
||||
);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "reject" }),
|
||||
[applyDecisions, approvalDecisions],
|
||||
);
|
||||
|
||||
const handleAlwaysAllow = useCallback(
|
||||
(id: string, name: string) => {
|
||||
const current = alwaysAllowTools ?? NO_TOOLS;
|
||||
const allowed = current.includes(name) ? current : [...current, name];
|
||||
setAlwaysAllowTools(allowed);
|
||||
const next = { ...approvalDecisions, [id]: "approve" as const };
|
||||
for (const tc of pendingApprovals ?? []) {
|
||||
if (tc.name === name) next[tc.id] = "approve";
|
||||
}
|
||||
applyDecisions(next);
|
||||
},
|
||||
[
|
||||
alwaysAllowTools,
|
||||
applyDecisions,
|
||||
approvalDecisions,
|
||||
pendingApprovals,
|
||||
setAlwaysAllowTools,
|
||||
],
|
||||
);
|
||||
|
||||
const clearAlwaysAllowTools = useCallback(
|
||||
() => setAlwaysAllowTools(NO_TOOLS),
|
||||
[setAlwaysAllowTools],
|
||||
);
|
||||
|
||||
const recentEventIds = useMemo(() => {
|
||||
@@ -174,7 +281,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
if (similar) return similar.results.map((e) => e.id);
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = getEventIdsFromToolCalls(calls);
|
||||
if (events.length > 0) return events.map((e) => e.id);
|
||||
}
|
||||
return [];
|
||||
@@ -197,19 +304,25 @@ export default function ChatPage() {
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setStreaming(null);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedEventId(null);
|
||||
setError(null);
|
||||
setPendingApprovals(null);
|
||||
setApprovalDecisions({});
|
||||
}, []);
|
||||
|
||||
const handleEditSubmit = useCallback(
|
||||
@@ -260,7 +373,7 @@ export default function ChatPage() {
|
||||
const calls = toolCallsForMessage(msg, responses);
|
||||
const contentText = hasText(msg.content) ? msg.content : "";
|
||||
const similar = getFindSimilarObjectsFromToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
|
||||
const events = similar ? [] : getEventIdsFromToolCalls(calls);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
@@ -324,6 +437,8 @@ export default function ChatPage() {
|
||||
setShowStats={setShowStats}
|
||||
autoScroll={autoScroll ?? true}
|
||||
setAutoScroll={setAutoScroll}
|
||||
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
|
||||
clearAlwaysAllowTools={clearAlwaysAllowTools}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -335,6 +450,20 @@ export default function ChatPage() {
|
||||
{hasStarted ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||
{renderList.map((msg, i) => renderMessage(msg, i))}
|
||||
{pendingApprovals && !streaming && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingApprovals.map((tc) => (
|
||||
<ToolApprovalCard
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
decision={approvalDecisions[tc.id]}
|
||||
onApprove={handleApprove}
|
||||
onAlwaysAllow={handleAlwaysAllow}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{streaming &&
|
||||
!finalShown &&
|
||||
(streaming.content || streaming.reasoning ? (
|
||||
@@ -391,7 +520,12 @@ export default function ChatPage() {
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
disabled={pendingApprovals != null}
|
||||
placeholder={
|
||||
pendingApprovals != null
|
||||
? t("approval.placeholder")
|
||||
: t("placeholder")
|
||||
}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||
import { LuActivity, LuHardDrive, LuSearchCode } from "react-icons/lu";
|
||||
import {
|
||||
LuActivity,
|
||||
LuHardDrive,
|
||||
LuHeartPulse,
|
||||
LuSearchCode,
|
||||
} from "react-icons/lu";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import Logo from "@/components/Logo";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
@@ -15,9 +20,16 @@ import { useHashState } from "@/hooks/use-overlay-state";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import EnrichmentMetrics from "@/views/system/EnrichmentMetrics";
|
||||
import HealthMetrics from "@/views/system/HealthMetrics";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const allMetrics = ["general", "enrichments", "storage", "cameras"] as const;
|
||||
const allMetrics = [
|
||||
"health",
|
||||
"general",
|
||||
"enrichments",
|
||||
"storage",
|
||||
"cameras",
|
||||
] as const;
|
||||
type SystemMetric = (typeof allMetrics)[number];
|
||||
|
||||
function System() {
|
||||
@@ -44,8 +56,9 @@ function System() {
|
||||
// stats page
|
||||
|
||||
const [page, setPage] = useHashState<SystemMetric>();
|
||||
// useHashState yields "" with no hash, which ?? would not catch
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(
|
||||
page ?? "general",
|
||||
page || "health",
|
||||
setPage,
|
||||
100,
|
||||
);
|
||||
@@ -56,9 +69,7 @@ function System() {
|
||||
// Track which tabs have been visited so we can keep them mounted after first visit.
|
||||
// Using a ref updated during render avoids extra render cycles from state/effects.
|
||||
const visitedTabsRef = useRef(new Set<string>());
|
||||
if (page) {
|
||||
visitedTabsRef.current.add(page);
|
||||
}
|
||||
visitedTabsRef.current.add(pageToggle);
|
||||
const visitedTabs = visitedTabsRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,6 +109,7 @@ function System() {
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
@@ -126,6 +138,11 @@ function System() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visitedTabs.has("health") && (
|
||||
<div className={pageToggle == "health" ? "contents" : "hidden"}>
|
||||
<HealthMetrics />
|
||||
</div>
|
||||
)}
|
||||
{visitedTabs.has("general") && (
|
||||
<div className={page == "general" ? "contents" : "hidden"}>
|
||||
<GeneralMetrics
|
||||
|
||||
@@ -20,11 +20,21 @@ export type ChatMessage = {
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
id?: string;
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
response?: string;
|
||||
};
|
||||
|
||||
export type ToolDecision = "approve" | "reject";
|
||||
|
||||
/** A state-changing tool call the backend paused on, awaiting the user. */
|
||||
export type PendingToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type HealthSeverity = "error" | "warning" | "info";
|
||||
|
||||
/**
|
||||
* One row in the Health tab's Notices list. Every source (registry notices
|
||||
* now, live stats, config checks, and stream checks in PR 2) maps into this
|
||||
* shape so the row component never needs to know where a problem came from.
|
||||
*/
|
||||
export type HealthProblem = {
|
||||
id: string;
|
||||
severity: HealthSeverity;
|
||||
/** camera name or other scope shown as a chip before the text */
|
||||
scope?: string;
|
||||
/** whether scope is a camera name, so the chip can use the friendly name */
|
||||
scopeIsCamera?: boolean;
|
||||
text: string;
|
||||
/** muted line under the text, for example when it was first seen */
|
||||
meta?: ReactNode;
|
||||
/** in-app route for a settings icon link */
|
||||
link?: string;
|
||||
/** docs path for an external link, rendered in PR 2 */
|
||||
docLink?: string;
|
||||
/** absolute URL rendered as an external link (the update notice's release page) */
|
||||
externalLink?: string;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
export type NoticeMode = "state" | "event";
|
||||
export type NoticeSeverity = "error" | "warning" | "info";
|
||||
export type NoticeCategory =
|
||||
| "camera"
|
||||
| "detector"
|
||||
| "storage"
|
||||
| "model"
|
||||
| "system";
|
||||
export type NoticeKind =
|
||||
| "ffmpeg_crash_loop"
|
||||
| "detector_stuck"
|
||||
| "model_download_failed"
|
||||
| "retention_unmet"
|
||||
| "update_available";
|
||||
|
||||
export type Notice = {
|
||||
id: string;
|
||||
kind: NoticeKind;
|
||||
mode: NoticeMode;
|
||||
severity: NoticeSeverity;
|
||||
category: NoticeCategory;
|
||||
scope: string | null;
|
||||
params: Record<string, string | number | boolean>;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
count: number;
|
||||
dismissed_at: number | null;
|
||||
};
|
||||
|
||||
export type NoticeStats = {
|
||||
kind: NoticeKind;
|
||||
occurrences: number;
|
||||
dismissals: number;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
};
|
||||
+56
-14
@@ -1,4 +1,10 @@
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatStats,
|
||||
PendingToolCall,
|
||||
ToolCall,
|
||||
ToolDecision,
|
||||
} from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Streamed delta of the assistant's final answer text. */
|
||||
@@ -11,6 +17,10 @@ export type StreamChatCallbacks = {
|
||||
onChain: (chain: ChatMessage[]) => void;
|
||||
/** Token/timing stats for the turn. */
|
||||
onStats: (stats: ChatStats) => void;
|
||||
/** The backend paused before running state-changing tools; the chain
|
||||
* emitted just before this ends with the assistant message requesting
|
||||
* them. Resend that chain with `toolDecisions` to continue. */
|
||||
onApprovalRequired?: (toolCalls: PendingToolCall[]) => void;
|
||||
/** Called when the stream sends an error or fetch fails. */
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
@@ -30,6 +40,7 @@ type StatsChunk = {
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "messages"; messages: ChatMessage[] }
|
||||
| { type: "approval_required"; tool_calls: PendingToolCall[] }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "reasoning"; delta: string }
|
||||
| StatsChunk;
|
||||
@@ -40,6 +51,8 @@ type StreamChunk =
|
||||
*/
|
||||
export type StreamChatOptions = {
|
||||
enableThinking?: boolean;
|
||||
/** Decisions for tool calls that paused for approval, keyed by call id. */
|
||||
toolDecisions?: Record<string, ToolDecision>;
|
||||
};
|
||||
|
||||
export async function streamChatCompletion(
|
||||
@@ -55,6 +68,7 @@ export async function streamChatCompletion(
|
||||
onReasoningDelta,
|
||||
onChain,
|
||||
onStats,
|
||||
onApprovalRequired,
|
||||
onError,
|
||||
onDone,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
@@ -68,6 +82,9 @@ export async function streamChatCompletion(
|
||||
if (options.enableThinking !== undefined) {
|
||||
body.enable_thinking = options.enableThinking;
|
||||
}
|
||||
if (options.toolDecisions && Object.keys(options.toolDecisions).length) {
|
||||
body.tool_decisions = options.toolDecisions;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -103,6 +120,10 @@ export async function streamChatCompletion(
|
||||
onChain(data.messages ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "approval_required") {
|
||||
onApprovalRequired?.(data.tool_calls ?? []);
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "content" && data.delta !== undefined) {
|
||||
onContentDelta(data.delta);
|
||||
return "continue";
|
||||
@@ -198,6 +219,7 @@ export function toolCallsForMessage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: args,
|
||||
response: responses.get(tc.id),
|
||||
@@ -205,28 +227,48 @@ export function toolCallsForMessage(
|
||||
});
|
||||
}
|
||||
|
||||
/** Human-readable tool name: "search_objects" -> "Search Objects". */
|
||||
export function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const hasStringId = (item: unknown): item is { id: string } =>
|
||||
!!item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string";
|
||||
|
||||
/**
|
||||
* Parse search_objects tool call response(s) into event ids for thumbnails.
|
||||
* Collect event ids from tool responses that reference tracked objects:
|
||||
* search_objects returns a list of events and get_event_image a single one.
|
||||
*/
|
||||
export function getEventIdsFromSearchObjectsToolCalls(
|
||||
export function getEventIdsFromToolCalls(
|
||||
toolCalls: ToolCall[] | undefined,
|
||||
): { id: string }[] {
|
||||
if (!toolCalls?.length) return [];
|
||||
const results: { id: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (item: unknown) => {
|
||||
if (hasStringId(item) && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
results.push({ id: item.id });
|
||||
}
|
||||
};
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
|
||||
if (!tc.response?.trim()) continue;
|
||||
if (tc.name !== "search_objects" && tc.name !== "get_event_image") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(tc.response) as unknown;
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof (item as { id: unknown }).id === "string"
|
||||
) {
|
||||
results.push({ id: (item as { id: string }).id });
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(push);
|
||||
} else {
|
||||
push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** GitHub release page for a Frigate version such as "0.19.0". */
|
||||
export function releaseUrl(version: string): string {
|
||||
return `https://github.com/blakeblackshear/frigate/releases/tag/v${version}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import NoticesPane from "@/components/health/NoticesPane";
|
||||
|
||||
export default function HealthMetrics() {
|
||||
return (
|
||||
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
|
||||
<NoticesPane />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user