mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Semantic Search Triggers (#18969)
* semantic trigger test * database and model * config * embeddings maintainer and trigger post-processor * api to create, edit, delete triggers * frontend and i18n keys * use thumbnail and description for trigger types * image picker tweaks * initial sync * thumbnail file management * clean up logs and use saved thumbnail on frontend * publish mqtt messages * webpush changes to enable trigger notifications * add enabled switch * add triggers from explore * renaming and deletion fixes * fix typing * UI updates and add last triggering event time and link * log exception instead of return in endpoint * highlight entry in UI when triggered * save and delete thumbnails directly * remove alert action for now and add descriptions * tweaks * clean up * fix types * docs * docs tweaks * docs * reuse enum
This commit is contained in:
parent
2b4a773f9b
commit
57bb0cc397
@ -840,6 +840,23 @@ cameras:
|
||||
# By default the cameras are sorted alphabetically.
|
||||
order: 0
|
||||
|
||||
# Optional: Configuration for triggers to automate actions based on semantic search results.
|
||||
triggers:
|
||||
# Required: Unique identifier for the trigger (generated automatically from nickname if not specified).
|
||||
trigger_name:
|
||||
# Required: Enable or disable the trigger. (default: shown below)
|
||||
enabled: true
|
||||
# Type of trigger, either `thumbnail` for image-based matching or `description` for text-based matching. (default: none)
|
||||
type: thumbnail
|
||||
# Reference data for matching, either an event ID for `thumbnail` or a text string for `description`. (default: none)
|
||||
data: 1751565549.853251-b69j73
|
||||
# Similarity threshold for triggering. (default: none)
|
||||
threshold: 0.7
|
||||
# List of actions to perform when the trigger fires. (default: none)
|
||||
# Available options: `notification` (send a webpush notification)
|
||||
actions:
|
||||
- notification
|
||||
|
||||
# Optional: Configuration for AI generated tracked object descriptions
|
||||
genai:
|
||||
# Optional: Enable AI description generation (default: shown below)
|
||||
|
@ -102,3 +102,41 @@ See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_
|
||||
4. Make your search language and tone closely match exactly what you're looking for. If you are using thumbnail search, **phrase your query as an image caption**. Searching for "red car" may not work as well as "red sedan driving down a residential street on a sunny day".
|
||||
5. Semantic search on thumbnails tends to return better results when matching large subjects that take up most of the frame. Small things like "cat" tend to not work well.
|
||||
6. Experiment! Find a tracked object you want to test and start typing keywords and phrases to see what works for you.
|
||||
|
||||
## Triggers
|
||||
|
||||
Triggers utilize semantic search to automate actions when a tracked object matches a specified image or description. Triggers can be configured so that Frigate executes a specific actions when a tracked object's image or description matches a predefined image or text, based on a similarity threshold. Triggers are managed per camera and can be configured via the Frigate UI in the Settings page under the Triggers tab.
|
||||
|
||||
### Configuration
|
||||
|
||||
Triggers are defined within the `semantic_search` configuration for each camera in your Frigate configuration file or through the UI. Each trigger consists of a `type` (either `thumbnail` or `description`), a `data` field (the reference image event ID or text), a `threshold` for similarity matching, and a list of `actions` to perform when the trigger fires.
|
||||
|
||||
#### Managing Triggers in the UI
|
||||
|
||||
1. Navigate to the **Settings** page and select the **Triggers** tab.
|
||||
2. Choose a camera from the dropdown menu to view or manage its triggers.
|
||||
3. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
|
||||
4. In the **Create Trigger** dialog:
|
||||
- Enter a **Name** for the trigger (e.g., "red_car_alert").
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
|
||||
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
|
||||
- Set the **Threshold** for similarity matching.
|
||||
- Select **Actions** to perform when the trigger fires.
|
||||
5. Save the trigger to update the configuration and store the embedding in the database.
|
||||
|
||||
When a trigger fires, the UI highlights the trigger with a blue outline for 3 seconds for easy identification.
|
||||
|
||||
### Usage and Best Practices
|
||||
|
||||
1. **Thumbnail Triggers**: Select a representative image (event ID) from the Explore page that closely matches the object you want to detect. For best results, choose images where the object is prominent and fills most of the frame.
|
||||
2. **Description Triggers**: Write concise, specific text descriptions (e.g., "Person in a red jacket") that align with the tracked object’s description. Avoid vague terms to improve matching accuracy.
|
||||
3. **Threshold Tuning**: Adjust the threshold to balance sensitivity and specificity. A higher threshold (e.g., 0.8) requires closer matches, reducing false positives but potentially missing similar objects. A lower threshold (e.g., 0.6) is more inclusive but may trigger more often.
|
||||
4. **Using Explore**: Use the context menu or right-click / long-press on a tracked object in the Grid View in Explore to quickly add a trigger based on the tracked object's thumbnail.
|
||||
5. **Editing triggers**: For the best experience, triggers should be edited via the UI. However, Frigate will ensure triggers edited in the config will be synced with triggers created and edited in the UI.
|
||||
|
||||
### Notes
|
||||
|
||||
- Triggers rely on the same Jina AI CLIP models (V1 or V2) used for semantic search. Ensure `semantic_search` is enabled and properly configured.
|
||||
- Reindexing embeddings (via the UI or `reindex: True`) does not affect trigger configurations but may update the embeddings used for matching.
|
||||
- For optimal performance, use a system with sufficient RAM (8GB minimum, 16GB recommended) and a GPU for `large` model configurations, as described in the Semantic Search requirements.
|
||||
|
@ -192,6 +192,20 @@ Message published for each changed review item. The first message is published w
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/triggers`
|
||||
|
||||
Message published when a trigger defined in a camera's `semantic_search` configuration fires.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "car_trigger",
|
||||
"camera": "driveway",
|
||||
"event_id": "1751565549.853251-b69j73",
|
||||
"type": "thumbnail",
|
||||
"score": 0.85
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/stats`
|
||||
|
||||
Same data available at `/api/stats` published at a configurable interval.
|
||||
|
@ -2,6 +2,8 @@ from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.config.classification import TriggerType
|
||||
|
||||
|
||||
class EventsSubLabelBody(BaseModel):
|
||||
subLabel: str = Field(title="Sub label", max_length=100)
|
||||
@ -45,3 +47,9 @@ class EventsDeleteBody(BaseModel):
|
||||
|
||||
class SubmitPlusBody(BaseModel):
|
||||
include_annotation: int = Field(default=1)
|
||||
|
||||
|
||||
class TriggerEmbeddingBody(BaseModel):
|
||||
type: TriggerType
|
||||
data: str
|
||||
threshold: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Event apis."""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@ -10,6 +11,7 @@ from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
@ -34,6 +36,7 @@ from frigate.api.defs.request.events_body import (
|
||||
EventsLPRBody,
|
||||
EventsSubLabelBody,
|
||||
SubmitPlusBody,
|
||||
TriggerEmbeddingBody,
|
||||
)
|
||||
from frigate.api.defs.response.event_response import (
|
||||
EventCreateResponse,
|
||||
@ -44,11 +47,12 @@ from frigate.api.defs.response.event_response import (
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.comms.event_metadata_updater import EventMetadataTypeEnum
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.const import CLIPS_DIR, TRIGGER_DIR
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.models import Event, ReviewSegment, Timeline
|
||||
from frigate.models import Event, ReviewSegment, Timeline, Trigger
|
||||
from frigate.track.object_processing import TrackedObject
|
||||
from frigate.util.builtin import get_tz_modifiers
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -1255,6 +1259,38 @@ def regenerate_description(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/description/generate",
|
||||
response_model=GenericResponse,
|
||||
# dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def generate_description_embedding(
|
||||
request: Request,
|
||||
body: EventsDescriptionBody,
|
||||
):
|
||||
new_description = body.description
|
||||
|
||||
# If semantic search is enabled, update the index
|
||||
if request.app.frigate_config.semantic_search.enabled:
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
if len(new_description) > 0:
|
||||
result = context.generate_description_embedding(
|
||||
new_description,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Embedding for description is {result}"
|
||||
if result
|
||||
else "Failed to generate embedding",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
@ -1403,3 +1439,397 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
|
||||
content=({"success": True, "message": "Event successfully ended."}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/trigger/embedding",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def create_trigger_embedding(
|
||||
request: Request,
|
||||
body: TriggerEmbeddingBody,
|
||||
camera: str,
|
||||
name: str,
|
||||
):
|
||||
try:
|
||||
if not request.app.frigate_config.semantic_search.enabled:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Semantic search is not enabled",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check if trigger already exists
|
||||
if (
|
||||
Trigger.select()
|
||||
.where(Trigger.camera == camera, Trigger.name == name)
|
||||
.exists()
|
||||
):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Trigger {camera}:{name} already exists",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
# Generate embedding based on type
|
||||
embedding = None
|
||||
if body.type == "description":
|
||||
embedding = context.generate_description_embedding(body.data)
|
||||
elif body.type == "thumbnail":
|
||||
try:
|
||||
event: Event = Event.get(Event.id == body.data)
|
||||
except DoesNotExist:
|
||||
# TODO: check triggers directory for image
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Failed to fetch event for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
return
|
||||
|
||||
if thumbnail := get_event_thumbnail_bytes(event):
|
||||
cursor = context.db.execute_sql(
|
||||
"""
|
||||
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
|
||||
""",
|
||||
[body.data],
|
||||
)
|
||||
|
||||
row = cursor.fetchone() if cursor else None
|
||||
|
||||
if row:
|
||||
query_embedding = row[0]
|
||||
embedding = np.frombuffer(query_embedding, dtype=np.float32)
|
||||
else:
|
||||
# Extract valid thumbnail
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
|
||||
if thumbnail is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Failed to get thumbnail for {body.data} for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
embedding = context.generate_image_embedding(
|
||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||
)
|
||||
|
||||
if embedding is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Failed to generate embedding for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if body.type == "thumbnail":
|
||||
# Save image to the triggers directory
|
||||
try:
|
||||
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
|
||||
with open(
|
||||
os.path.join(TRIGGER_DIR, camera, f"{body.data}.webp"), "wb"
|
||||
) as f:
|
||||
f.write(thumbnail)
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera}: {e}"
|
||||
)
|
||||
|
||||
Trigger.create(
|
||||
camera=camera,
|
||||
name=name,
|
||||
type=body.type,
|
||||
data=body.data,
|
||||
threshold=body.threshold,
|
||||
model=request.app.frigate_config.semantic_search.model,
|
||||
embedding=np.array(embedding, dtype=np.float32).tobytes(),
|
||||
triggering_event_id="",
|
||||
last_triggered=None,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger created successfully for {camera}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error creating trigger embedding: {str(e)}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/trigger/embedding/{camera}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def update_trigger_embedding(
|
||||
request: Request,
|
||||
camera: str,
|
||||
name: str,
|
||||
body: TriggerEmbeddingBody,
|
||||
):
|
||||
try:
|
||||
if not request.app.frigate_config.semantic_search.enabled:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Semantic search is not enabled",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
# Generate embedding based on type
|
||||
embedding = None
|
||||
if body.type == "description":
|
||||
embedding = context.generate_description_embedding(body.data)
|
||||
elif body.type == "thumbnail":
|
||||
webp_file = body.data + ".webp"
|
||||
webp_path = os.path.join(TRIGGER_DIR, camera, webp_file)
|
||||
|
||||
try:
|
||||
event: Event = Event.get(Event.id == body.data)
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Event {body.data} is not a tracked object for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
# Extract valid thumbnail
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
|
||||
with open(webp_path, "wb") as f:
|
||||
f.write(thumbnail)
|
||||
except DoesNotExist:
|
||||
# check triggers directory for image
|
||||
if not os.path.exists(webp_path):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Failed to fetch event for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
else:
|
||||
# Load the image from the triggers directory
|
||||
with open(webp_path, "rb") as f:
|
||||
thumbnail = f.read()
|
||||
|
||||
embedding = context.generate_image_embedding(
|
||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||
)
|
||||
|
||||
if embedding is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Failed to generate embedding for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check if trigger exists for upsert
|
||||
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
|
||||
|
||||
if trigger:
|
||||
# Update existing trigger
|
||||
if trigger.data != body.data: # Delete old thumbnail only if data changes
|
||||
try:
|
||||
os.remove(os.path.join(TRIGGER_DIR, camera, f"{trigger.data}.webp"))
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}: {e}"
|
||||
)
|
||||
|
||||
Trigger.update(
|
||||
data=body.data,
|
||||
model=request.app.frigate_config.semantic_search.model,
|
||||
embedding=np.array(embedding, dtype=np.float32).tobytes(),
|
||||
threshold=body.threshold,
|
||||
triggering_event_id="",
|
||||
last_triggered=None,
|
||||
).where(Trigger.camera == camera, Trigger.name == name).execute()
|
||||
else:
|
||||
# Create new trigger (for rename case)
|
||||
Trigger.create(
|
||||
camera=camera,
|
||||
name=name,
|
||||
type=body.type,
|
||||
data=body.data,
|
||||
threshold=body.threshold,
|
||||
model=request.app.frigate_config.semantic_search.model,
|
||||
embedding=np.array(embedding, dtype=np.float32).tobytes(),
|
||||
triggering_event_id="",
|
||||
last_triggered=None,
|
||||
)
|
||||
|
||||
if body.type == "thumbnail":
|
||||
# Save image to the triggers directory
|
||||
try:
|
||||
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
|
||||
with open(
|
||||
os.path.join(TRIGGER_DIR, camera, f"{body.data}.webp"), "wb"
|
||||
) as f:
|
||||
f.write(thumbnail)
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to write thumbnail for trigger with data {body.data} in {camera}: {e}"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger updated successfully for {camera}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error updating trigger embedding: {str(e)}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/trigger/embedding/{camera}/{name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def delete_trigger_embedding(
|
||||
request: Request,
|
||||
camera: str,
|
||||
name: str,
|
||||
):
|
||||
try:
|
||||
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
|
||||
if trigger is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Trigger {camera}:{name} not found",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
deleted = (
|
||||
Trigger.delete()
|
||||
.where(Trigger.camera == camera, Trigger.name == name)
|
||||
.execute()
|
||||
)
|
||||
if deleted == 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error deleting trigger {camera}:{name}",
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
os.remove(os.path.join(TRIGGER_DIR, camera, f"{trigger.data}.webp"))
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}: {e}"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": f"Trigger deleted successfully for {camera}:{name}",
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Error deleting trigger embedding: {str(e)}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/triggers/status/{camera_name}",
|
||||
response_model=dict,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def get_triggers_status(
|
||||
camera_name: str,
|
||||
):
|
||||
try:
|
||||
# Fetch all triggers for the specified camera
|
||||
triggers = Trigger.select().where(Trigger.camera == camera_name)
|
||||
|
||||
# Prepare the response with trigger status
|
||||
status = {
|
||||
trigger.name: {
|
||||
"last_triggered": trigger.last_triggered.timestamp()
|
||||
if trigger.last_triggered
|
||||
else None,
|
||||
"triggering_event_id": trigger.triggering_event_id
|
||||
if trigger.triggering_event_id
|
||||
else None,
|
||||
}
|
||||
for trigger in triggers
|
||||
}
|
||||
|
||||
if not status:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"No triggers found for camera {camera_name}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return {"success": True, "triggers": status}
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error fetching trigger status"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
@ -38,6 +38,7 @@ from frigate.const import (
|
||||
MODEL_CACHE_DIR,
|
||||
RECORD_DIR,
|
||||
THUMB_DIR,
|
||||
TRIGGER_DIR,
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
@ -55,6 +56,7 @@ from frigate.models import (
|
||||
Regions,
|
||||
ReviewSegment,
|
||||
Timeline,
|
||||
Trigger,
|
||||
User,
|
||||
)
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
@ -121,6 +123,9 @@ class FrigateApp:
|
||||
if self.config.face_recognition.enabled:
|
||||
dirs.append(FACE_DIR)
|
||||
|
||||
if self.config.semantic_search.enabled:
|
||||
dirs.append(TRIGGER_DIR)
|
||||
|
||||
for d in dirs:
|
||||
if not os.path.exists(d) and not os.path.islink(d):
|
||||
logger.info(f"Creating directory: {d}")
|
||||
@ -286,6 +291,7 @@ class FrigateApp:
|
||||
ReviewSegment,
|
||||
Timeline,
|
||||
User,
|
||||
Trigger,
|
||||
]
|
||||
self.db.bind(models)
|
||||
|
||||
|
@ -186,6 +186,28 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||
return
|
||||
self.send_alert(decoded)
|
||||
if topic == "triggers":
|
||||
decoded = json.loads(payload)
|
||||
|
||||
camera = decoded["camera"]
|
||||
name = decoded["name"]
|
||||
|
||||
# ensure notifications are enabled and the specific trigger has
|
||||
# notification action enabled
|
||||
if (
|
||||
not self.config.cameras[camera].notifications.enabled
|
||||
or name not in self.config.cameras[camera].semantic_search.triggers
|
||||
or "notification"
|
||||
not in self.config.cameras[camera]
|
||||
.semantic_search.triggers[name]
|
||||
.actions
|
||||
):
|
||||
return
|
||||
|
||||
if self.is_camera_suspended(camera):
|
||||
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||
return
|
||||
self.send_trigger(decoded)
|
||||
elif topic == "notification_test":
|
||||
if not self.config.notifications.enabled and not any(
|
||||
cam.notifications.enabled for cam in self.config.cameras.values()
|
||||
@ -264,6 +286,23 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing notification: {str(e)}")
|
||||
|
||||
def _within_cooldown(self, camera: str) -> bool:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if now - self.last_notification_time < self.config.notifications.cooldown:
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - in global cooldown period"
|
||||
)
|
||||
return True
|
||||
if (
|
||||
now - self.last_camera_notification_time[camera]
|
||||
< self.config.cameras[camera].notifications.cooldown
|
||||
):
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - in camera-specific cooldown period"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def send_notification_test(self) -> None:
|
||||
if not self.config.notifications.email:
|
||||
return
|
||||
@ -290,24 +329,7 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
camera: str = payload["after"]["camera"]
|
||||
current_time = datetime.datetime.now().timestamp()
|
||||
|
||||
# Check global cooldown period
|
||||
if (
|
||||
current_time - self.last_notification_time
|
||||
< self.config.notifications.cooldown
|
||||
):
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - in global cooldown period"
|
||||
)
|
||||
return
|
||||
|
||||
# Check camera-specific cooldown period
|
||||
if (
|
||||
current_time - self.last_camera_notification_time[camera]
|
||||
< self.config.cameras[camera].notifications.cooldown
|
||||
):
|
||||
logger.debug(
|
||||
f"Skipping notification for {camera} - in camera-specific cooldown period"
|
||||
)
|
||||
if self._within_cooldown(camera):
|
||||
return
|
||||
|
||||
self.check_registrations()
|
||||
@ -362,6 +384,48 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
||||
|
||||
self.cleanup_registrations()
|
||||
|
||||
def send_trigger(self, payload: dict[str, Any]) -> None:
|
||||
if not self.config.notifications.email:
|
||||
return
|
||||
|
||||
camera: str = payload["camera"]
|
||||
current_time = datetime.datetime.now().timestamp()
|
||||
|
||||
if self._within_cooldown(camera):
|
||||
return
|
||||
|
||||
self.check_registrations()
|
||||
|
||||
self.last_camera_notification_time[camera] = current_time
|
||||
self.last_notification_time = current_time
|
||||
|
||||
trigger_type = payload["type"]
|
||||
event_id = payload["event_id"]
|
||||
name = payload["name"]
|
||||
score = payload["score"]
|
||||
|
||||
title = f"{name.replace('_', ' ')} triggered on {titlecase(camera.replace('_', ' '))}"
|
||||
message = f"{titlecase(trigger_type)} trigger fired for {titlecase(camera.replace('_', ' '))} with score {score:.2f}"
|
||||
image = f"clips/triggers/{camera}/{event_id}.webp"
|
||||
|
||||
direct_url = f"/explore?event_id={event_id}"
|
||||
ttl = 0
|
||||
|
||||
logger.debug(f"Sending push notification for {camera}, trigger name {name}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
title=title,
|
||||
message=message,
|
||||
direct_url=direct_url,
|
||||
image=image,
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
self.cleanup_registrations()
|
||||
|
||||
def stop(self) -> None:
|
||||
logger.info("Closing notification queue")
|
||||
self.notification_thread.join()
|
||||
|
@ -22,6 +22,7 @@ from ..classification import (
|
||||
AudioTranscriptionConfig,
|
||||
CameraFaceRecognitionConfig,
|
||||
CameraLicensePlateRecognitionConfig,
|
||||
CameraSemanticSearchConfig,
|
||||
)
|
||||
from .audio import AudioConfig
|
||||
from .birdseye import BirdseyeCameraConfig
|
||||
@ -91,6 +92,10 @@ class CameraConfig(FrigateBaseModel):
|
||||
review: ReviewConfig = Field(
|
||||
default_factory=ReviewConfig, title="Review configuration."
|
||||
)
|
||||
semantic_search: CameraSemanticSearchConfig = Field(
|
||||
default_factory=CameraSemanticSearchConfig,
|
||||
title="Semantic search configuration.",
|
||||
)
|
||||
snapshots: SnapshotsConfig = Field(
|
||||
default_factory=SnapshotsConfig, title="Snapshot configuration."
|
||||
)
|
||||
|
@ -23,6 +23,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
record = "record"
|
||||
remove = "remove" # for removing a camera
|
||||
review = "review"
|
||||
semantic_search = "semantic_search" # for semantic search triggers
|
||||
snapshots = "snapshots"
|
||||
zones = "zones"
|
||||
|
||||
@ -106,6 +107,8 @@ class CameraConfigUpdateSubscriber:
|
||||
config.record = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.review:
|
||||
config.review = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.semantic_search:
|
||||
config.semantic_search = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.snapshots:
|
||||
config.snapshots = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.zones:
|
||||
|
@ -10,6 +10,7 @@ __all__ = [
|
||||
"CameraLicensePlateRecognitionConfig",
|
||||
"FaceRecognitionConfig",
|
||||
"SemanticSearchConfig",
|
||||
"CameraSemanticSearchConfig",
|
||||
"LicensePlateRecognitionConfig",
|
||||
]
|
||||
|
||||
@ -24,6 +25,15 @@ class EnrichmentsDeviceEnum(str, Enum):
|
||||
CPU = "CPU"
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
THUMBNAIL = "thumbnail"
|
||||
DESCRIPTION = "description"
|
||||
|
||||
|
||||
class TriggerAction(str, Enum):
|
||||
NOTIFICATION = "notification"
|
||||
|
||||
|
||||
class AudioTranscriptionConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable audio transcription.")
|
||||
language: str = Field(
|
||||
@ -113,6 +123,32 @@ class SemanticSearchConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
|
||||
class TriggerConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable this trigger")
|
||||
type: TriggerType = Field(default=TriggerType.DESCRIPTION, title="Type of trigger")
|
||||
data: str = Field(title="Trigger content (text phrase or image ID)")
|
||||
threshold: float = Field(
|
||||
title="Confidence score required to run the trigger",
|
||||
default=0.8,
|
||||
gt=0.0,
|
||||
le=1.0,
|
||||
)
|
||||
actions: Optional[List[TriggerAction]] = Field(
|
||||
default=[], title="Actions to perform when trigger is matched"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
|
||||
class CameraSemanticSearchConfig(FrigateBaseModel):
|
||||
triggers: Optional[Dict[str, TriggerConfig]] = Field(
|
||||
default=None,
|
||||
title="Trigger actions on tracked objects that match existing thumbnails or descriptions",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
||||
|
||||
|
||||
class FaceRecognitionConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable face recognition.")
|
||||
model_size: str = Field(
|
||||
|
@ -11,6 +11,7 @@ EXPORT_DIR = f"{BASE_DIR}/exports"
|
||||
FACE_DIR = f"{CLIPS_DIR}/faces"
|
||||
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
|
||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
FRIGATE_LOCALHOST = "http://127.0.0.1:5000"
|
||||
|
233
frigate/data_processing/post/semantic_trigger.py
Normal file
233
frigate/data_processing/post/semantic_trigger.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""Post time processor to trigger actions based on similar embeddings."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.embeddings.util import ZScoreNormalization
|
||||
from frigate.models import Event, Trigger
|
||||
from frigate.util.builtin import cosine_distance
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WRITE_DEBUG_IMAGES = False
|
||||
|
||||
|
||||
class SemanticTriggerProcessor(PostProcessorApi):
|
||||
def __init__(
|
||||
self,
|
||||
db: SqliteVecQueueDatabase,
|
||||
config: FrigateConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
metrics: DataProcessorMetrics,
|
||||
embeddings,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
self.db = db
|
||||
self.embeddings = embeddings
|
||||
self.requestor = requestor
|
||||
self.trigger_embeddings: list[np.ndarray] = []
|
||||
|
||||
self.thumb_stats = ZScoreNormalization()
|
||||
self.desc_stats = ZScoreNormalization()
|
||||
|
||||
# load stats from disk
|
||||
try:
|
||||
with open(os.path.join(CONFIG_DIR, ".search_stats.json"), "r") as f:
|
||||
data = json.loads(f.read())
|
||||
self.thumb_stats.from_dict(data["thumb_stats"])
|
||||
self.desc_stats.from_dict(data["desc_stats"])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
event_id = data["event_id"]
|
||||
camera = data["camera"]
|
||||
process_type = data["type"]
|
||||
|
||||
if self.config.cameras[camera].semantic_search.triggers is None:
|
||||
return
|
||||
|
||||
triggers = (
|
||||
Trigger.select(
|
||||
Trigger.camera,
|
||||
Trigger.name,
|
||||
Trigger.data,
|
||||
Trigger.type,
|
||||
Trigger.embedding,
|
||||
Trigger.threshold,
|
||||
)
|
||||
.where(Trigger.camera == camera)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
for trigger in triggers:
|
||||
if (
|
||||
trigger["name"]
|
||||
not in self.config.cameras[camera].semantic_search.triggers
|
||||
or not self.config.cameras[camera]
|
||||
.semantic_search.triggers[trigger["name"]]
|
||||
.enabled
|
||||
):
|
||||
logger.debug(
|
||||
f"Trigger {trigger['name']} is disabled for camera {camera}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Processing {trigger['type']} trigger for {event_id} on {trigger['camera']}: {trigger['name']}"
|
||||
)
|
||||
|
||||
trigger_embedding = np.frombuffer(trigger["embedding"], dtype=np.float32)
|
||||
|
||||
# Get embeddings based on type
|
||||
thumbnail_embedding = None
|
||||
description_embedding = None
|
||||
|
||||
if process_type == "image":
|
||||
cursor = self.db.execute_sql(
|
||||
"""
|
||||
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
|
||||
""",
|
||||
[event_id],
|
||||
)
|
||||
row = cursor.fetchone() if cursor else None
|
||||
if row:
|
||||
thumbnail_embedding = np.frombuffer(row[0], dtype=np.float32)
|
||||
|
||||
if process_type == "text":
|
||||
cursor = self.db.execute_sql(
|
||||
"""
|
||||
SELECT description_embedding FROM vec_descriptions WHERE id = ?
|
||||
""",
|
||||
[event_id],
|
||||
)
|
||||
row = cursor.fetchone() if cursor else None
|
||||
if row:
|
||||
description_embedding = np.frombuffer(row[0], dtype=np.float32)
|
||||
|
||||
# Skip processing if we don't have any embeddings
|
||||
if thumbnail_embedding is None and description_embedding is None:
|
||||
logger.debug(f"No embeddings found for {event_id}")
|
||||
return
|
||||
|
||||
# Determine which embedding to compare based on trigger type
|
||||
if (
|
||||
trigger["type"] in ["text", "thumbnail"]
|
||||
and thumbnail_embedding is not None
|
||||
):
|
||||
data_embedding = thumbnail_embedding
|
||||
normalized_distance = self.thumb_stats.normalize(
|
||||
[cosine_distance(data_embedding, trigger_embedding)],
|
||||
save_stats=False,
|
||||
)[0]
|
||||
elif trigger["type"] == "description" and description_embedding is not None:
|
||||
data_embedding = description_embedding
|
||||
normalized_distance = self.desc_stats.normalize(
|
||||
[cosine_distance(data_embedding, trigger_embedding)],
|
||||
save_stats=False,
|
||||
)[0]
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
similarity = 1 - normalized_distance
|
||||
|
||||
logger.debug(
|
||||
f"Trigger {trigger['name']} ({trigger['data'] if trigger['type'] == 'text' or trigger['type'] == 'description' else 'image'}): "
|
||||
f"normalized distance: {normalized_distance:.4f}, "
|
||||
f"similarity: {similarity:.4f}, threshold: {trigger['threshold']}"
|
||||
)
|
||||
|
||||
# Check if similarity meets threshold
|
||||
if similarity >= trigger["threshold"]:
|
||||
logger.info(
|
||||
f"Trigger {trigger['name']} activated with similarity {similarity:.4f}"
|
||||
)
|
||||
|
||||
# Update the trigger's last_triggered and triggering_event_id
|
||||
Trigger.update(
|
||||
last_triggered=datetime.datetime.now(), triggering_event_id=event_id
|
||||
).where(
|
||||
Trigger.camera == camera, Trigger.name == trigger["name"]
|
||||
).execute()
|
||||
|
||||
# Always publish MQTT message
|
||||
self.requestor.send_data(
|
||||
"triggers",
|
||||
json.dumps(
|
||||
{
|
||||
"name": trigger["name"],
|
||||
"camera": camera,
|
||||
"event_id": event_id,
|
||||
"type": trigger["type"],
|
||||
"score": similarity,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.config.cameras[camera]
|
||||
.semantic_search.triggers[trigger["name"]]
|
||||
.actions
|
||||
):
|
||||
# TODO: handle actions for the trigger
|
||||
# notifications already handled by webpush
|
||||
pass
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
return
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
return
|
||||
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
|
||||
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
||||
thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
font_scale = 0.5
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
cv2.putText(
|
||||
thumbnail,
|
||||
f"{similarity:.4f}",
|
||||
(10, 30),
|
||||
font,
|
||||
fontScale=font_scale,
|
||||
color=(0, 255, 0),
|
||||
thickness=2,
|
||||
)
|
||||
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
cv2.imwrite(
|
||||
f"debug/frames/trigger-{event_id}_{current_time}.jpg",
|
||||
thumbnail,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
pass
|
@ -287,3 +287,15 @@ class EmbeddingsContext:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.transcribe_audio.value, {"event": event}
|
||||
)
|
||||
|
||||
def generate_description_embedding(self, text: str) -> None:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.embed_description.value,
|
||||
{"id": None, "description": text, "upsert": False},
|
||||
)
|
||||
|
||||
def generate_image_embedding(self, event_id: str, thumbnail: bytes) -> None:
|
||||
return self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.embed_thumbnail.value,
|
||||
{"id": str(event_id), "thumbnail": str(thumbnail), "upsert": False},
|
||||
)
|
||||
|
@ -6,20 +6,25 @@ import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from numpy import ndarray
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist, IntegrityError
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.comms.embeddings_updater import (
|
||||
EmbeddingsRequestEnum,
|
||||
)
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.const import (
|
||||
CONFIG_DIR,
|
||||
TRIGGER_DIR,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_MODEL_STATE,
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.models import Event
|
||||
from frigate.models import Event, Trigger
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
@ -165,7 +170,7 @@ class Embeddings:
|
||||
|
||||
def embed_thumbnail(
|
||||
self, event_id: str, thumbnail: bytes, upsert: bool = True
|
||||
) -> ndarray:
|
||||
) -> np.ndarray:
|
||||
"""Embed thumbnail and optionally insert into DB.
|
||||
|
||||
@param: event_id in Events DB
|
||||
@ -192,7 +197,7 @@ class Embeddings:
|
||||
|
||||
def batch_embed_thumbnail(
|
||||
self, event_thumbs: dict[str, bytes], upsert: bool = True
|
||||
) -> list[ndarray]:
|
||||
) -> list[np.ndarray]:
|
||||
"""Embed thumbnails and optionally insert into DB.
|
||||
|
||||
@param: event_thumbs Map of Event IDs in DB to thumbnail bytes in jpg format
|
||||
@ -225,7 +230,7 @@ class Embeddings:
|
||||
|
||||
def embed_description(
|
||||
self, event_id: str, description: str, upsert: bool = True
|
||||
) -> ndarray:
|
||||
) -> np.ndarray:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
embedding = self.text_embedding([description])[0]
|
||||
|
||||
@ -245,7 +250,7 @@ class Embeddings:
|
||||
|
||||
def batch_embed_description(
|
||||
self, event_descriptions: dict[str, str], upsert: bool = True
|
||||
) -> ndarray:
|
||||
) -> np.ndarray:
|
||||
start = datetime.datetime.now().timestamp()
|
||||
# upsert embeddings one by one to avoid token limit
|
||||
embeddings = []
|
||||
@ -401,3 +406,224 @@ class Embeddings:
|
||||
with self.reindex_lock:
|
||||
self.reindex_running = False
|
||||
self.reindex_thread = None
|
||||
|
||||
def sync_triggers(self) -> None:
|
||||
for camera in self.config.cameras.values():
|
||||
# Get all existing triggers for this camera
|
||||
existing_triggers = {
|
||||
trigger.name: trigger
|
||||
for trigger in Trigger.select().where(Trigger.camera == camera.name)
|
||||
}
|
||||
|
||||
# Get all configured trigger names
|
||||
configured_trigger_names = set(camera.semantic_search.triggers or {})
|
||||
|
||||
# Create or update triggers from config
|
||||
for trigger_name, trigger in (
|
||||
camera.semantic_search.triggers or {}
|
||||
).items():
|
||||
if trigger_name in existing_triggers:
|
||||
existing_trigger = existing_triggers[trigger_name]
|
||||
needs_embedding_update = False
|
||||
thumbnail_missing = False
|
||||
|
||||
# Check if data has changed or thumbnail is missing for thumbnail type
|
||||
if trigger.type == "thumbnail":
|
||||
thumbnail_path = os.path.join(
|
||||
TRIGGER_DIR, camera.name, f"{trigger.data}.webp"
|
||||
)
|
||||
try:
|
||||
event = Event.get(Event.id == trigger.data)
|
||||
if event.data.get("type") != "object":
|
||||
logger.warning(
|
||||
f"Event {trigger.data} is not a tracked object for {trigger.type} trigger"
|
||||
)
|
||||
continue # Skip if not an object
|
||||
|
||||
# Check if thumbnail needs to be updated (data changed or missing)
|
||||
if (
|
||||
existing_trigger.data != trigger.data
|
||||
or not os.path.exists(thumbnail_path)
|
||||
):
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
if not thumbnail:
|
||||
logger.warning(
|
||||
f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}."
|
||||
)
|
||||
continue
|
||||
self.write_trigger_thumbnail(
|
||||
camera.name, trigger.data, thumbnail
|
||||
)
|
||||
thumbnail_missing = True
|
||||
except DoesNotExist:
|
||||
logger.warning(
|
||||
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
||||
)
|
||||
continue
|
||||
|
||||
# Update existing trigger if data has changed
|
||||
if (
|
||||
existing_trigger.type != trigger.type
|
||||
or existing_trigger.data != trigger.data
|
||||
or existing_trigger.threshold != trigger.threshold
|
||||
):
|
||||
existing_trigger.type = trigger.type
|
||||
existing_trigger.data = trigger.data
|
||||
existing_trigger.threshold = trigger.threshold
|
||||
needs_embedding_update = True
|
||||
|
||||
# Check if embedding is missing or needs update
|
||||
if (
|
||||
not existing_trigger.embedding
|
||||
or needs_embedding_update
|
||||
or thumbnail_missing
|
||||
):
|
||||
existing_trigger.embedding = self._calculate_trigger_embedding(
|
||||
trigger
|
||||
)
|
||||
needs_embedding_update = True
|
||||
|
||||
if needs_embedding_update:
|
||||
existing_trigger.save()
|
||||
else:
|
||||
# Create new trigger
|
||||
try:
|
||||
try:
|
||||
event: Event = Event.get(Event.id == trigger.data)
|
||||
except DoesNotExist:
|
||||
logger.warning(
|
||||
f"Event ID {trigger.data} for trigger {trigger_name} does not exist."
|
||||
)
|
||||
continue
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
logger.warning(
|
||||
f"Event ID {trigger.data} for trigger {trigger_name} is not a tracked object."
|
||||
)
|
||||
continue
|
||||
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
|
||||
if not thumbnail:
|
||||
logger.warning(
|
||||
f"Unable to retrieve thumbnail for event ID {trigger.data} for {trigger_name}."
|
||||
)
|
||||
continue
|
||||
|
||||
self.write_trigger_thumbnail(
|
||||
camera.name, trigger.data, thumbnail
|
||||
)
|
||||
|
||||
# Calculate embedding for new trigger
|
||||
embedding = self._calculate_trigger_embedding(trigger)
|
||||
|
||||
Trigger.create(
|
||||
camera=camera.name,
|
||||
name=trigger_name,
|
||||
type=trigger.type,
|
||||
data=trigger.data,
|
||||
threshold=trigger.threshold,
|
||||
model=self.config.semantic_search.model,
|
||||
embedding=embedding,
|
||||
triggering_event_id="",
|
||||
last_triggered=None,
|
||||
)
|
||||
|
||||
except IntegrityError:
|
||||
pass # Handle duplicate creation attempts
|
||||
|
||||
# Remove triggers that are no longer in config
|
||||
triggers_to_remove = (
|
||||
set(existing_triggers.keys()) - configured_trigger_names
|
||||
)
|
||||
if triggers_to_remove:
|
||||
Trigger.delete().where(
|
||||
Trigger.camera == camera.name, Trigger.name.in_(triggers_to_remove)
|
||||
).execute()
|
||||
for trigger_name in triggers_to_remove:
|
||||
self.remove_trigger_thumbnail(camera.name, trigger_name)
|
||||
|
||||
def write_trigger_thumbnail(
|
||||
self, camera: str, event_id: str, thumbnail: bytes
|
||||
) -> None:
|
||||
"""Write the thumbnail to the trigger directory."""
|
||||
try:
|
||||
os.makedirs(os.path.join(TRIGGER_DIR, camera), exist_ok=True)
|
||||
with open(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp"), "wb") as f:
|
||||
f.write(thumbnail)
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {event_id} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to write thumbnail for trigger with data {event_id} in {camera}: {e}"
|
||||
)
|
||||
|
||||
def remove_trigger_thumbnail(self, camera: str, event_id: str) -> None:
|
||||
"""Write the thumbnail to the trigger directory."""
|
||||
try:
|
||||
os.remove(os.path.join(TRIGGER_DIR, camera, f"{event_id}.webp"))
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {event_id} in {camera}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete thumbnail for trigger with data {event_id} in {camera}: {e}"
|
||||
)
|
||||
|
||||
def _calculate_trigger_embedding(self, trigger) -> bytes:
|
||||
"""Calculate embedding for a trigger based on its type and data."""
|
||||
if trigger.type == "description":
|
||||
logger.debug(f"Generating embedding for trigger description {trigger.name}")
|
||||
embedding = self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.embed_description.value,
|
||||
{"id": None, "description": trigger.data, "upsert": False},
|
||||
)
|
||||
return embedding.astype(np.float32).tobytes()
|
||||
|
||||
elif trigger.type == "thumbnail":
|
||||
# For image triggers, trigger.data should be an image ID
|
||||
# Try to get embedding from vec_thumbnails table first
|
||||
cursor = self.db.execute_sql(
|
||||
"SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?",
|
||||
[trigger.data],
|
||||
)
|
||||
row = cursor.fetchone() if cursor else None
|
||||
if row:
|
||||
return row[0] # Already in bytes format
|
||||
else:
|
||||
logger.debug(
|
||||
f"No thumbnail embedding found for image ID: {trigger.data}, generating from saved trigger thumbnail"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(
|
||||
os.path.join(
|
||||
TRIGGER_DIR, trigger.camera, f"{trigger.data}.webp"
|
||||
),
|
||||
"rb",
|
||||
) as f:
|
||||
thumbnail = f.read()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to read thumbnail for trigger {trigger.name} with ID {trigger.data}: {e}"
|
||||
)
|
||||
return b""
|
||||
|
||||
logger.debug(
|
||||
f"Generating embedding for trigger thumbnail {trigger.name} with ID {trigger.data}"
|
||||
)
|
||||
embedding = self.requestor.send_data(
|
||||
EmbeddingsRequestEnum.embed_thumbnail.value,
|
||||
{
|
||||
"id": str(trigger.data),
|
||||
"thumbnail": str(thumbnail),
|
||||
"upsert": False,
|
||||
},
|
||||
)
|
||||
return embedding.astype(np.float32).tobytes()
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown trigger type: {trigger.type}")
|
||||
return b""
|
||||
|
@ -14,7 +14,10 @@ import numpy as np
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder
|
||||
from frigate.comms.embeddings_updater import (
|
||||
EmbeddingsRequestEnum,
|
||||
EmbeddingsResponder,
|
||||
)
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataPublisher,
|
||||
EventMetadataSubscriber,
|
||||
@ -46,6 +49,7 @@ from frigate.data_processing.post.audio_transcription import (
|
||||
from frigate.data_processing.post.license_plate import (
|
||||
LicensePlatePostProcessor,
|
||||
)
|
||||
from frigate.data_processing.post.semantic_trigger import SemanticTriggerProcessor
|
||||
from frigate.data_processing.real_time.api import RealTimeProcessorApi
|
||||
from frigate.data_processing.real_time.bird import BirdRealTimeProcessor
|
||||
from frigate.data_processing.real_time.custom_classification import (
|
||||
@ -60,7 +64,7 @@ from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataE
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
||||
from frigate.genai import get_genai_client
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.models import Event, Recordings, Trigger
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import serialize
|
||||
from frigate.util.image import (
|
||||
@ -93,7 +97,11 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.config_updater = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove],
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.semantic_search,
|
||||
],
|
||||
)
|
||||
|
||||
# Configure Frigate DB
|
||||
@ -109,7 +117,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
),
|
||||
load_vec_extension=True,
|
||||
)
|
||||
models = [Event, Recordings]
|
||||
models = [Event, Recordings, Trigger]
|
||||
db.bind(models)
|
||||
|
||||
if config.semantic_search.enabled:
|
||||
@ -119,6 +127,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if config.semantic_search.reindex:
|
||||
self.embeddings.reindex()
|
||||
|
||||
# Sync semantic search triggers in db with config
|
||||
self.embeddings.sync_triggers()
|
||||
|
||||
# create communication for updating event descriptions
|
||||
self.requestor = InterProcessRequestor()
|
||||
|
||||
@ -211,6 +222,17 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
|
||||
)
|
||||
|
||||
if self.config.semantic_search.enabled:
|
||||
self.post_processors.append(
|
||||
SemanticTriggerProcessor(
|
||||
db,
|
||||
self.config,
|
||||
self.requestor,
|
||||
metrics,
|
||||
self.embeddings,
|
||||
)
|
||||
)
|
||||
|
||||
self.stop_event = stop_event
|
||||
self.tracked_events: dict[str, list[Any]] = {}
|
||||
self.early_request_sent: dict[str, bool] = {}
|
||||
@ -387,33 +409,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
event_id, camera, updated_db = ended
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
# call any defined post processors
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, LicensePlatePostProcessor):
|
||||
recordings_available = self.recordings_available_through.get(camera)
|
||||
if (
|
||||
recordings_available is not None
|
||||
and event_id in self.detected_license_plates
|
||||
and self.config.cameras[camera].type != "lpr"
|
||||
):
|
||||
processor.process_data(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"camera": camera,
|
||||
"recordings_available": self.recordings_available_through[
|
||||
camera
|
||||
],
|
||||
"obj_data": self.detected_license_plates[event_id][
|
||||
"obj_data"
|
||||
],
|
||||
},
|
||||
PostProcessDataEnum.recording,
|
||||
)
|
||||
elif isinstance(processor, AudioTranscriptionPostProcessor):
|
||||
continue
|
||||
else:
|
||||
processor.process_data(event_id, PostProcessDataEnum.event_id)
|
||||
|
||||
# expire in realtime processors
|
||||
for processor in self.realtime_processors:
|
||||
processor.expire_object(event_id, camera)
|
||||
@ -450,6 +445,41 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
):
|
||||
self._process_genai_description(event, camera_config, thumbnail)
|
||||
|
||||
# call any defined post processors
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, LicensePlatePostProcessor):
|
||||
recordings_available = self.recordings_available_through.get(camera)
|
||||
if (
|
||||
recordings_available is not None
|
||||
and event_id in self.detected_license_plates
|
||||
and self.config.cameras[camera].type != "lpr"
|
||||
):
|
||||
processor.process_data(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"camera": camera,
|
||||
"recordings_available": self.recordings_available_through[
|
||||
camera
|
||||
],
|
||||
"obj_data": self.detected_license_plates[event_id][
|
||||
"obj_data"
|
||||
],
|
||||
},
|
||||
PostProcessDataEnum.recording,
|
||||
)
|
||||
elif isinstance(processor, AudioTranscriptionPostProcessor):
|
||||
continue
|
||||
elif isinstance(processor, SemanticTriggerProcessor):
|
||||
processor.process_data(
|
||||
{"event_id": event_id, "camera": camera, "type": "image"},
|
||||
PostProcessDataEnum.tracked_object,
|
||||
)
|
||||
else:
|
||||
processor.process_data(
|
||||
{"event_id": event_id, "camera": camera},
|
||||
PostProcessDataEnum.tracked_object,
|
||||
)
|
||||
|
||||
# Delete tracked events based on the event_id
|
||||
if event_id in self.tracked_events:
|
||||
del self.tracked_events[event_id]
|
||||
@ -658,6 +688,16 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if self.config.semantic_search.enabled:
|
||||
self.embeddings.embed_description(event.id, description)
|
||||
|
||||
# Check semantic trigger for this description
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, SemanticTriggerProcessor):
|
||||
processor.process_data(
|
||||
{"event_id": event.id, "camera": event.camera, "type": "text"},
|
||||
PostProcessDataEnum.tracked_object,
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Generated description for %s (%d images): %s",
|
||||
event.id,
|
||||
|
@ -1,6 +1,8 @@
|
||||
from peewee import (
|
||||
BlobField,
|
||||
BooleanField,
|
||||
CharField,
|
||||
CompositeKey,
|
||||
DateTimeField,
|
||||
FloatField,
|
||||
ForeignKeyField,
|
||||
@ -132,3 +134,18 @@ class User(Model): # type: ignore[misc]
|
||||
)
|
||||
password_hash = CharField(null=False, max_length=120)
|
||||
notification_tokens = JSONField()
|
||||
|
||||
|
||||
class Trigger(Model): # type: ignore[misc]
|
||||
camera = CharField(max_length=20)
|
||||
name = CharField()
|
||||
type = CharField(max_length=10)
|
||||
data = TextField()
|
||||
threshold = FloatField()
|
||||
model = CharField(max_length=30)
|
||||
embedding = BlobField()
|
||||
triggering_event_id = CharField(max_length=30)
|
||||
last_triggered = DateTimeField()
|
||||
|
||||
class Meta:
|
||||
primary_key = CompositeKey("camera", "name")
|
||||
|
@ -428,3 +428,19 @@ def sanitize_float(value):
|
||||
if isinstance(value, (int, float)) and not math.isfinite(value):
|
||||
return 0.0
|
||||
return value
|
||||
|
||||
|
||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
return 1 - cosine_distance(a, b)
|
||||
|
||||
|
||||
def cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Returns cosine distance to match sqlite-vec's calculation."""
|
||||
dot = np.dot(a, b)
|
||||
a_mag = np.dot(a, a) # ||a||^2
|
||||
b_mag = np.dot(b, b) # ||b||^2
|
||||
|
||||
if a_mag == 0 or b_mag == 0:
|
||||
return 1.0
|
||||
|
||||
return 1.0 - (dot / (np.sqrt(a_mag) * np.sqrt(b_mag)))
|
||||
|
50
migrations/031_create_trigger_table.py
Normal file
50
migrations/031_create_trigger_table.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Peewee migrations -- 031_create_trigger_table.py.
|
||||
|
||||
This migration creates the Trigger table to track semantic search triggers for cameras.
|
||||
|
||||
Some examples (model - class or model_name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import peewee as pw
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS trigger (
|
||||
camera VARCHAR(20) NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
model VARCHAR(30) NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
threshold REAL,
|
||||
embedding BLOB,
|
||||
triggering_event_id VARCHAR(30),
|
||||
last_triggered DATETIME,
|
||||
PRIMARY KEY (camera, name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql("DROP TABLE IF EXISTS trigger")
|
@ -109,5 +109,12 @@
|
||||
"markAsReviewed": "Mark as reviewed",
|
||||
"deleteNow": "Delete Now"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
"selectImage": "Select a tracked object's thumbnail",
|
||||
"search": {
|
||||
"placeholder": "Search by label or sub label..."
|
||||
},
|
||||
"noImages": "No thumbnails found for this camera"
|
||||
}
|
||||
}
|
||||
|
@ -175,6 +175,10 @@
|
||||
"label": "Find similar",
|
||||
"aria": "Find similar tracked objects"
|
||||
},
|
||||
"addTrigger": {
|
||||
"label": "Add trigger",
|
||||
"aria": "Add a trigger for this tracked object"
|
||||
},
|
||||
"audioTranscription": {
|
||||
"label": "Transcribe",
|
||||
"aria": "Request audio transcription"
|
||||
|
@ -644,5 +644,100 @@
|
||||
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
|
||||
"error": "Failed to save config changes: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"documentTitle": "Triggers",
|
||||
"management": {
|
||||
"title": "Trigger Management",
|
||||
"desc": "Manage triggers for {{camera}}. Use the thumbnail type to trigger on similar thumbnails to your selected tracked object, and the description type to trigger on similar descriptions to text you specify."
|
||||
},
|
||||
"addTrigger": "Add Trigger",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"content": "Content",
|
||||
"threshold": "Threshold",
|
||||
"actions": "Actions",
|
||||
"noTriggers": "No triggers configured for this camera.",
|
||||
"edit": "Edit",
|
||||
"deleteTrigger": "Delete Trigger",
|
||||
"lastTriggered": "Last triggered"
|
||||
},
|
||||
"type": {
|
||||
"thumbnail": "Thumbnail",
|
||||
"description": "Description"
|
||||
},
|
||||
"actions": {
|
||||
"alert": "Mark as Alert",
|
||||
"notification": "Send Notification"
|
||||
},
|
||||
"dialog": {
|
||||
"createTrigger": {
|
||||
"title": "Create Trigger",
|
||||
"desc": "Create a trigger for camera {{camera}}"
|
||||
},
|
||||
"editTrigger": {
|
||||
"title": "Edit Trigger",
|
||||
"desc": "Edit the settings for trigger on camera {{camera}}"
|
||||
},
|
||||
"deleteTrigger": {
|
||||
"title": "Delete Trigger",
|
||||
"desc": "Are you sure you want to delete the trigger <strong>{{triggerName}}</strong>? This action cannot be undone."
|
||||
},
|
||||
"form": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"placeholder": "Enter trigger name",
|
||||
"error": {
|
||||
"minLength": "Name must be at least 2 characters long.",
|
||||
"invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.",
|
||||
"alreadyExists": "A trigger with this name already exists for this camera."
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Enable or disable this trigger"
|
||||
},
|
||||
"type": {
|
||||
"title": "Type",
|
||||
"placeholder": "Select trigger type"
|
||||
},
|
||||
"content": {
|
||||
"title": "Content",
|
||||
"imagePlaceholder": "Select an image",
|
||||
"textPlaceholder": "Enter text content",
|
||||
"imageDesc": "Select an image to trigger this action when a similar image is detected.",
|
||||
"textDesc": "Enter text to trigger this action when a similar tracked object description is detected.",
|
||||
"error": {
|
||||
"required": "Content is required."
|
||||
}
|
||||
},
|
||||
"threshold": {
|
||||
"title": "Threshold",
|
||||
"error": {
|
||||
"min": "Threshold must be at least 0",
|
||||
"max": "Threshold must be at most 1"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"title": "Actions",
|
||||
"desc": "By default, Frigate fires an MQTT message for all triggers. Choose an additional action to perform when this trigger fires.",
|
||||
"error": {
|
||||
"min": "At least one action must be selected."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"createTrigger": "Trigger {{name}} created successfully.",
|
||||
"updateTrigger": "Trigger {{name}} updated successfully.",
|
||||
"deleteTrigger": "Trigger {{name}} deleted successfully."
|
||||
},
|
||||
"error": {
|
||||
"createTriggerFailed": "Failed to create trigger: {{errorMessage}}",
|
||||
"updateTriggerFailed": "Failed to update trigger: {{errorMessage}}",
|
||||
"deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
ModelState,
|
||||
ToggleableSetting,
|
||||
TrackedObjectUpdateReturnType,
|
||||
TriggerStatus,
|
||||
} from "@/types/ws";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { createContainer } from "react-tracked";
|
||||
@ -572,3 +573,13 @@ export function useNotificationTest(): {
|
||||
} = useWs("notification_test", "notification_test");
|
||||
return { payload: payload as string, send };
|
||||
}
|
||||
|
||||
export function useTriggers(): { payload: TriggerStatus } {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("triggers", "");
|
||||
const parsed = payload
|
||||
? JSON.parse(payload as string)
|
||||
: { name: "", camera: "", event_id: "", type: "", score: 0 };
|
||||
return { payload: useDeepMemo(parsed) };
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ type SearchThumbnailProps = {
|
||||
refreshResults: () => void;
|
||||
showObjectLifecycle: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
};
|
||||
|
||||
export default function SearchThumbnailFooter({
|
||||
@ -24,6 +25,7 @@ export default function SearchThumbnailFooter({
|
||||
refreshResults,
|
||||
showObjectLifecycle,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
}: SearchThumbnailProps) {
|
||||
const { t } = useTranslation(["views/search"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -61,6 +63,7 @@ export default function SearchThumbnailFooter({
|
||||
refreshResults={refreshResults}
|
||||
showObjectLifecycle={showObjectLifecycle}
|
||||
showSnapshot={showSnapshot}
|
||||
addTrigger={addTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -41,6 +41,7 @@ import {
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { BsFillLightningFill } from "react-icons/bs";
|
||||
|
||||
type SearchResultActionsProps = {
|
||||
searchResult: SearchResult;
|
||||
@ -48,6 +49,7 @@ type SearchResultActionsProps = {
|
||||
refreshResults: () => void;
|
||||
showObjectLifecycle: () => void;
|
||||
showSnapshot: () => void;
|
||||
addTrigger: () => void;
|
||||
isContextMenu?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
@ -58,6 +60,7 @@ export default function SearchResultActions({
|
||||
refreshResults,
|
||||
showObjectLifecycle,
|
||||
showSnapshot,
|
||||
addTrigger,
|
||||
isContextMenu = false,
|
||||
children,
|
||||
}: SearchResultActionsProps) {
|
||||
@ -138,6 +141,16 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.semantic_search?.enabled &&
|
||||
searchResult.data.type == "object" && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.addTrigger.aria")}
|
||||
onClick={addTrigger}
|
||||
>
|
||||
<BsFillLightningFill className="mr-2 size-4" />
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMobileOnly &&
|
||||
config?.plus?.enabled &&
|
||||
searchResult.has_snapshot &&
|
||||
|
416
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
416
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import ImagePicker from "@/components/overlay/ImagePicker";
|
||||
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type CreateTriggerDialogProps = {
|
||||
show: boolean;
|
||||
trigger: Trigger | null;
|
||||
selectedCamera: string;
|
||||
isLoading: boolean;
|
||||
onCreate: (
|
||||
enabled: boolean,
|
||||
name: string,
|
||||
type: TriggerType,
|
||||
data: string,
|
||||
threshold: number,
|
||||
actions: TriggerAction[],
|
||||
) => void;
|
||||
onEdit: (trigger: Trigger) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function CreateTriggerDialog({
|
||||
show,
|
||||
trigger,
|
||||
selectedCamera,
|
||||
isLoading,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onCancel,
|
||||
}: CreateTriggerDialogProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const existingTriggerNames = useMemo(() => {
|
||||
if (
|
||||
!config ||
|
||||
!selectedCamera ||
|
||||
!config.cameras[selectedCamera]?.semantic_search?.triggers
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(config.cameras[selectedCamera].semantic_search.triggers);
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const formSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
name: z
|
||||
.string()
|
||||
.min(2, t("triggers.dialog.form.name.error.minLength"))
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
t("triggers.dialog.form.name.error.invalidCharacters"),
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!existingTriggerNames.includes(value) || value === trigger?.name,
|
||||
t("triggers.dialog.form.name.error.alreadyExists"),
|
||||
),
|
||||
type: z.enum(["thumbnail", "description"]),
|
||||
data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
|
||||
threshold: z
|
||||
.number()
|
||||
.min(0, t("triggers.dialog.form.threshold.error.min"))
|
||||
.max(1, t("triggers.dialog.form.threshold.error.max")),
|
||||
actions: z.array(z.enum(["notification"])),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
enabled: trigger?.enabled ?? true,
|
||||
name: trigger?.name ?? "",
|
||||
type: trigger?.type ?? "description",
|
||||
data: trigger?.data ?? "",
|
||||
threshold: trigger?.threshold ?? 0.5,
|
||||
actions: trigger?.actions ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (trigger) {
|
||||
onEdit({ ...values });
|
||||
} else {
|
||||
onCreate(
|
||||
values.enabled,
|
||||
values.name,
|
||||
values.type,
|
||||
values.data,
|
||||
values.threshold,
|
||||
values.actions,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
form.reset({
|
||||
enabled: true,
|
||||
name: "",
|
||||
type: "description",
|
||||
data: "",
|
||||
threshold: 0.5,
|
||||
actions: [],
|
||||
});
|
||||
} else if (trigger) {
|
||||
form.reset(
|
||||
{
|
||||
enabled: trigger.enabled,
|
||||
name: trigger.name,
|
||||
type: trigger.type,
|
||||
data: trigger.data,
|
||||
threshold: trigger.threshold,
|
||||
actions: trigger.actions,
|
||||
},
|
||||
{ keepDirty: false, keepTouched: false }, // Reset validation state
|
||||
);
|
||||
// Trigger validation to ensure isValid updates
|
||||
// form.trigger();
|
||||
}
|
||||
}, [show, trigger, form]);
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
trigger
|
||||
? "triggers.dialog.editTrigger.title"
|
||||
: "triggers.dialog.createTrigger.title",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
trigger
|
||||
? "triggers.dialog.editTrigger.desc"
|
||||
: "triggers.dialog.createTrigger.desc",
|
||||
{ camera: selectedCamera },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 py-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("triggers.dialog.form.name.placeholder")}
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{t("enabled", { ns: "common" })}
|
||||
</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("triggers.dialog.form.enabled.description")}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"triggers.dialog.form.type.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="thumbnail">
|
||||
{t("triggers.type.thumbnail")}
|
||||
</SelectItem>
|
||||
<SelectItem value="description">
|
||||
{t("triggers.type.description")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.content.title")}
|
||||
</FormLabel>
|
||||
{form.watch("type") === "thumbnail" ? (
|
||||
<>
|
||||
<FormControl>
|
||||
<ImagePicker
|
||||
selectedImageId={field.value}
|
||||
setSelectedImageId={field.onChange}
|
||||
camera={selectedCamera}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("triggers.dialog.form.content.imageDesc")}
|
||||
</FormDescription>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"triggers.dialog.form.content.textPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("triggers.dialog.form.content.textDesc")}
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.threshold.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
placeholder="0.50"
|
||||
className="h-10"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
field.onChange(isNaN(value) ? 0 : value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="actions"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.actions.title")}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{["notification"].map((action) => (
|
||||
<div key={action} className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={form
|
||||
.watch("actions")
|
||||
.includes(action as TriggerAction)}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentActions = form.getValues("actions");
|
||||
if (checked) {
|
||||
form.setValue("actions", [
|
||||
...currentActions,
|
||||
action as TriggerAction,
|
||||
]);
|
||||
} else {
|
||||
form.setValue(
|
||||
"actions",
|
||||
currentActions.filter((a) => a !== action),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t(`triggers.actions.${action}`)}
|
||||
</FormLabel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("triggers.dialog.form.actions.desc")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
80
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
80
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trans } from "react-i18next";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
|
||||
type DeleteTriggerDialogProps = {
|
||||
show: boolean;
|
||||
triggerName: string;
|
||||
isLoading: boolean;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export default function DeleteTriggerDialog({
|
||||
show,
|
||||
triggerName,
|
||||
isLoading,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: DeleteTriggerDialogProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
|
||||
return (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("triggers.dialog.deleteTrigger.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
ns={"views/settings"}
|
||||
values={{ triggerName }}
|
||||
components={{ strong: <span className="font-medium" /> }}
|
||||
>
|
||||
triggers.dialog.deleteTrigger.desc
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.delete", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -60,7 +60,7 @@ export default function DeleteUserDialog({
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
className="flex flex-1 text-white"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
|
172
web/src/components/overlay/ImagePicker.tsx
Normal file
172
web/src/components/overlay/ImagePicker.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Event } from "@/types/event";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
|
||||
type ImagePickerProps = {
|
||||
selectedImageId?: string;
|
||||
setSelectedImageId?: (id: string) => void;
|
||||
camera: string;
|
||||
};
|
||||
|
||||
export default function ImagePicker({
|
||||
selectedImageId,
|
||||
setSelectedImageId,
|
||||
camera,
|
||||
}: ImagePickerProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { data: events } = useSWR<Event[]>(
|
||||
`events?camera=${camera}&limit=100`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const images = useMemo(() => {
|
||||
if (!events) return [];
|
||||
return events.filter(
|
||||
(event) =>
|
||||
(event.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(event.sub_label &&
|
||||
event.sub_label.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
searchTerm === "") &&
|
||||
event.camera === camera,
|
||||
);
|
||||
}, [events, searchTerm, camera]);
|
||||
|
||||
const selectedImage = useMemo(
|
||||
() => images.find((img) => img.id === selectedImageId),
|
||||
[images, selectedImageId],
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId(id);
|
||||
}
|
||||
setSearchTerm("");
|
||||
setOpen(false);
|
||||
},
|
||||
[setSelectedImageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{!selectedImageId ? (
|
||||
<Button
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
aria-label={t("imagePicker.selectImage")}
|
||||
>
|
||||
{t("imagePicker.selectImage")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="hover:cursor-pointer">
|
||||
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
selectedImage
|
||||
? `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||
: `${apiHost}clips/triggers/${camera}/${selectedImageId}.webp`
|
||||
}
|
||||
alt={selectedImage?.label || "Selected image"}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
<div className="text-sm smart-capitalize">
|
||||
{selectedImage?.label || selectedImageId}
|
||||
{selectedImage?.sub_label
|
||||
? ` (${selectedImage.sub_label})`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<IoClose
|
||||
className="mx-2 hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogTitle className="sr-only">
|
||||
{t("imagePicker.selectImage")}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop && "max-h-[75dvh] sm:max-w-xl md:max-w-3xl",
|
||||
isMobile && "px-4",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex flex-row items-center justify-between">
|
||||
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 pr-1">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-3 text-center text-sm text-muted-foreground">
|
||||
{t("imagePicker.noImages")}
|
||||
</div>
|
||||
) : (
|
||||
images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
|
||||
selectedImageId === image.id
|
||||
? "bg-selected text-white"
|
||||
: "hover:bg-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${apiHost}api/events/${image.id}/thumbnail.webp`}
|
||||
alt={image.label}
|
||||
className="rounded object-cover"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -476,6 +476,7 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
|
@ -45,6 +45,7 @@ import { isInIframe } from "@/utils/isIFrame";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TriggerView from "@/views/settings/TriggerView";
|
||||
|
||||
const allSettingsViews = [
|
||||
"ui",
|
||||
@ -52,6 +53,7 @@ const allSettingsViews = [
|
||||
"cameras",
|
||||
"masksAndZones",
|
||||
"motionTuner",
|
||||
"triggers",
|
||||
"debug",
|
||||
"users",
|
||||
"notifications",
|
||||
@ -171,7 +173,7 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask
|
||||
return !searchParams.has("object_mask");
|
||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||
});
|
||||
|
||||
useSearchEffect("camera", (camera: string) => {
|
||||
@ -179,8 +181,8 @@ export default function Settings() {
|
||||
if (cameraNames.includes(camera)) {
|
||||
setSelectedCamera(camera);
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask
|
||||
return !searchParams.has("object_mask");
|
||||
// don't clear url params if we're creating a new object mask or trigger
|
||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -229,7 +231,8 @@ export default function Settings() {
|
||||
{(page == "debug" ||
|
||||
page == "cameras" ||
|
||||
page == "masksAndZones" ||
|
||||
page == "motionTuner") && (
|
||||
page == "motionTuner" ||
|
||||
page == "triggers") && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
{page == "masksAndZones" && (
|
||||
<ZoneMaskFilterButton
|
||||
@ -274,6 +277,12 @@ export default function Settings() {
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page === "triggers" && (
|
||||
<TriggerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "users" && <AuthenticationView />}
|
||||
{page == "notifications" && (
|
||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IconName } from "@/components/icons/IconPicker";
|
||||
import { TriggerAction, TriggerType } from "./trigger";
|
||||
|
||||
export interface UiConfig {
|
||||
timezone?: string;
|
||||
@ -220,6 +221,17 @@ export interface CameraConfig {
|
||||
rtmp: {
|
||||
enabled: boolean;
|
||||
};
|
||||
semantic_search: {
|
||||
triggers: {
|
||||
[triggerName: string]: {
|
||||
enabled: boolean;
|
||||
type: TriggerType;
|
||||
data: string;
|
||||
threshold: number;
|
||||
actions: TriggerAction[];
|
||||
};
|
||||
};
|
||||
};
|
||||
snapshots: {
|
||||
bounding_box: boolean;
|
||||
clean_copy: boolean;
|
||||
|
11
web/src/types/trigger.ts
Normal file
11
web/src/types/trigger.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type TriggerType = "thumbnail" | "description";
|
||||
export type TriggerAction = "notification";
|
||||
|
||||
export type Trigger = {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
type: TriggerType;
|
||||
data: string;
|
||||
threshold: number;
|
||||
actions: TriggerAction[];
|
||||
};
|
@ -105,3 +105,11 @@ export type TrackedObjectUpdateReturnType = {
|
||||
timestamp?: number;
|
||||
text?: string;
|
||||
} | null;
|
||||
|
||||
export type TriggerStatus = {
|
||||
name: string;
|
||||
camera: string;
|
||||
event_id: string;
|
||||
type: string;
|
||||
score: number;
|
||||
};
|
||||
|
@ -431,6 +431,7 @@ function LibrarySelector({
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="text-white"
|
||||
onClick={() => {
|
||||
if (confirmDelete) {
|
||||
handleDeleteFace(confirmDelete);
|
||||
|
@ -218,6 +218,7 @@ function ExploreThumbnailImage({
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFindSimilar = () => {
|
||||
if (config?.semantic_search.enabled) {
|
||||
@ -233,6 +234,12 @@ function ExploreThumbnailImage({
|
||||
onSelectSearch(event, false, "snapshot");
|
||||
};
|
||||
|
||||
const handleAddTrigger = () => {
|
||||
navigate(
|
||||
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchResultActions
|
||||
searchResult={event}
|
||||
@ -240,6 +247,7 @@ function ExploreThumbnailImage({
|
||||
refreshResults={mutate}
|
||||
showObjectLifecycle={handleShowObjectLifecycle}
|
||||
showSnapshot={handleShowSnapshot}
|
||||
addTrigger={handleAddTrigger}
|
||||
isContextMenu={true}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
|
@ -32,6 +32,7 @@ import Chip from "@/components/indicators/Chip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type SearchViewProps = {
|
||||
search: string;
|
||||
@ -76,6 +77,7 @@ export default function SearchView({
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// grid
|
||||
|
||||
@ -648,6 +650,16 @@ export default function SearchView({
|
||||
showSnapshot={() =>
|
||||
onSelectSearch(value, false, "snapshot")
|
||||
}
|
||||
addTrigger={() => {
|
||||
if (
|
||||
config?.semantic_search.enabled &&
|
||||
value.data.type == "object"
|
||||
) {
|
||||
navigate(
|
||||
`/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
595
web/src/views/settings/TriggerView.tsx
Normal file
595
web/src/views/settings/TriggerView.tsx
Normal file
@ -0,0 +1,595 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { LuPlus, LuTrash, LuPencil, LuSearch } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
|
||||
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTriggers } from "@/api/ws";
|
||||
|
||||
type ConfigSetBody = {
|
||||
requires_restart: number;
|
||||
config_data: {
|
||||
cameras: {
|
||||
[key: string]: {
|
||||
semantic_search?: {
|
||||
triggers?: {
|
||||
[key: string]:
|
||||
| {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
data: string;
|
||||
threshold: number;
|
||||
actions: string[];
|
||||
}
|
||||
| "";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_topic?: string;
|
||||
};
|
||||
|
||||
type TriggerEmbeddingBody = {
|
||||
type: TriggerType;
|
||||
data: string;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
type TriggerViewProps = {
|
||||
selectedCamera: string;
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function TriggerView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: TriggerViewProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const { data: trigger_status, mutate } = useSWR(
|
||||
`/triggers/status/${selectedCamera}`,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(null);
|
||||
const [triggeredTrigger, setTriggeredTrigger] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const triggers = useMemo(() => {
|
||||
if (
|
||||
!config ||
|
||||
!selectedCamera ||
|
||||
!config.cameras[selectedCamera]?.semantic_search?.triggers
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(
|
||||
config.cameras[selectedCamera].semantic_search.triggers,
|
||||
).map(([name, trigger]) => ({
|
||||
enabled: trigger.enabled,
|
||||
name,
|
||||
type: trigger.type,
|
||||
data: trigger.data,
|
||||
threshold: trigger.threshold,
|
||||
actions: trigger.actions,
|
||||
}));
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
// watch websocket for updates
|
||||
const { payload: triggers_status_ws } = useTriggers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggers_status_ws) return;
|
||||
|
||||
mutate();
|
||||
|
||||
setTriggeredTrigger(triggers_status_ws.name);
|
||||
const target = document.querySelector(
|
||||
`#trigger-${triggers_status_ws.name}`,
|
||||
);
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
inline: "nearest",
|
||||
});
|
||||
const ring = target.querySelector(".trigger-ring");
|
||||
if (ring) {
|
||||
ring.classList.add(`outline-selected`);
|
||||
ring.classList.remove("outline-transparent");
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ring.classList.remove(`outline-selected`);
|
||||
ring.classList.add("outline-transparent");
|
||||
}, 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}, [triggers_status_ws, selectedCamera, mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("triggers.documentTitle");
|
||||
}, [t]);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
(trigger: Trigger, isEdit: boolean) => {
|
||||
setIsLoading(true);
|
||||
const { enabled, name, type, data, threshold, actions } = trigger;
|
||||
const embeddingBody: TriggerEmbeddingBody = { type, data, threshold };
|
||||
const embeddingUrl = isEdit
|
||||
? `/trigger/embedding/${selectedCamera}/${name}`
|
||||
: `/trigger/embedding?camera=${selectedCamera}&name=${name}`;
|
||||
const embeddingMethod = isEdit ? axios.put : axios.post;
|
||||
|
||||
embeddingMethod(embeddingUrl, embeddingBody)
|
||||
.then((embeddingResponse) => {
|
||||
if (embeddingResponse.data.success) {
|
||||
const configBody: ConfigSetBody = {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[selectedCamera]: {
|
||||
semantic_search: {
|
||||
triggers: {
|
||||
[name]: {
|
||||
enabled,
|
||||
type,
|
||||
data,
|
||||
threshold,
|
||||
actions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
|
||||
};
|
||||
|
||||
return axios
|
||||
.put("config/set", configBody)
|
||||
.then((configResponse) => {
|
||||
if (configResponse.status === 200) {
|
||||
updateConfig();
|
||||
toast.success(
|
||||
t(
|
||||
isEdit
|
||||
? "triggers.toast.success.updateTrigger"
|
||||
: "triggers.toast.success.createTrigger",
|
||||
{ name },
|
||||
),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
setUnsavedChanges(false);
|
||||
} else {
|
||||
throw new Error(configResponse.statusText);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(embeddingResponse.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setShowCreate(false);
|
||||
});
|
||||
},
|
||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||
);
|
||||
|
||||
const onCreate = useCallback(
|
||||
(
|
||||
enabled: boolean,
|
||||
name: string,
|
||||
type: TriggerType,
|
||||
data: string,
|
||||
threshold: number,
|
||||
actions: TriggerAction[],
|
||||
) => {
|
||||
setUnsavedChanges(true);
|
||||
saveToConfig({ enabled, name, type, data, threshold, actions }, false);
|
||||
},
|
||||
[saveToConfig, setUnsavedChanges],
|
||||
);
|
||||
|
||||
const onEdit = useCallback(
|
||||
(trigger: Trigger) => {
|
||||
setUnsavedChanges(true);
|
||||
setIsLoading(true);
|
||||
if (selectedTrigger?.name && selectedTrigger.name !== trigger.name) {
|
||||
// Handle rename: delete old trigger, update config, then save new trigger
|
||||
axios
|
||||
.delete(
|
||||
`/trigger/embedding/${selectedCamera}/${selectedTrigger.name}`,
|
||||
)
|
||||
.then((embeddingResponse) => {
|
||||
if (!embeddingResponse.data.success) {
|
||||
throw new Error(embeddingResponse.data.message);
|
||||
}
|
||||
const deleteConfigBody: ConfigSetBody = {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[selectedCamera]: {
|
||||
semantic_search: {
|
||||
triggers: {
|
||||
[selectedTrigger.name]: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
|
||||
};
|
||||
return axios.put("config/set", deleteConfigBody);
|
||||
})
|
||||
.then((configResponse) => {
|
||||
if (configResponse.status !== 200) {
|
||||
throw new Error(configResponse.statusText);
|
||||
}
|
||||
// Save new trigger
|
||||
saveToConfig(trigger, false);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Regular update without rename
|
||||
saveToConfig(trigger, true);
|
||||
}
|
||||
setSelectedTrigger(null);
|
||||
},
|
||||
[t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges],
|
||||
);
|
||||
|
||||
const onDelete = useCallback(
|
||||
(name: string) => {
|
||||
setUnsavedChanges(true);
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/trigger/embedding/${selectedCamera}/${name}`)
|
||||
.then((embeddingResponse) => {
|
||||
if (embeddingResponse.data.success) {
|
||||
const configBody: ConfigSetBody = {
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[selectedCamera]: {
|
||||
semantic_search: {
|
||||
triggers: {
|
||||
[name]: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
update_topic: `config/cameras/${selectedCamera}/semantic_search`,
|
||||
};
|
||||
|
||||
return axios
|
||||
.put("config/set", configBody)
|
||||
.then((configResponse) => {
|
||||
if (configResponse.status === 200) {
|
||||
updateConfig();
|
||||
toast.success(
|
||||
t("triggers.toast.success.deleteTrigger", { name }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
setUnsavedChanges(false);
|
||||
} else {
|
||||
throw new Error(configResponse.statusText);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(embeddingResponse.data.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("triggers.toast.error.deleteTriggerFailed", { errorMessage }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setShowDelete(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[t, updateConfig, selectedCamera, setUnsavedChanges],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCamera) {
|
||||
setSelectedTrigger(null);
|
||||
setShowCreate(false);
|
||||
setShowDelete(false);
|
||||
setUnsavedChanges(false);
|
||||
}
|
||||
}, [selectedCamera, setUnsavedChanges]);
|
||||
|
||||
// for adding a trigger with event id via explore context menu
|
||||
|
||||
useSearchEffect("event_id", (eventId: string) => {
|
||||
if (!config || isLoading) {
|
||||
return false;
|
||||
}
|
||||
setShowCreate(true);
|
||||
setSelectedTrigger({
|
||||
enabled: true,
|
||||
name: "",
|
||||
type: "thumbnail",
|
||||
data: eventId,
|
||||
threshold: 0.5,
|
||||
actions: [],
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!config || !selectedCamera) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("triggers.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("triggers.management.desc", { camera: selectedCamera })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label={t("triggers.addTrigger")}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(null);
|
||||
setShowCreate(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
{t("triggers.addTrigger")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||
<div className="h-full overflow-auto p-0">
|
||||
{triggers.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<p className="text-center text-muted-foreground">
|
||||
{t("triggers.table.noTriggers")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{triggers.map((trigger) => (
|
||||
<div
|
||||
key={trigger.name}
|
||||
id={`trigger-${trigger.name}`}
|
||||
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500",
|
||||
triggeredTrigger === trigger.name
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={cn(
|
||||
"truncate text-lg font-medium",
|
||||
!trigger.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{trigger.name}
|
||||
</h3>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
|
||||
!trigger.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Badge
|
||||
variant={
|
||||
trigger.type === "thumbnail"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className={
|
||||
trigger.type === "thumbnail"
|
||||
? "bg-primary/20 text-primary hover:bg-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t(`triggers.type.${trigger.type}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!trigger_status?.triggers[trigger.name]
|
||||
?.triggering_event_id && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
{t("triggers.table.lastTriggered")}:{" "}
|
||||
{trigger_status &&
|
||||
trigger_status.triggers[trigger.name]
|
||||
?.last_triggered
|
||||
? formatUnixTimestampToDateTime(
|
||||
trigger_status.triggers[trigger.name]
|
||||
?.last_triggered,
|
||||
{
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t(
|
||||
"time.formattedTimestamp2.24hour",
|
||||
{
|
||||
ns: "common",
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"time.formattedTimestamp2.12hour",
|
||||
{
|
||||
ns: "common",
|
||||
},
|
||||
),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
},
|
||||
)
|
||||
: "Never"}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LuSearch className="ml-2 size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("details.item.button.viewInExplore", {
|
||||
ns: "views/explore",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(trigger);
|
||||
setShowCreate(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("triggers.table.edit")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 text-white"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(trigger);
|
||||
setShowDelete(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuTrash className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("triggers.table.deleteTrigger")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTriggerDialog
|
||||
show={showCreate}
|
||||
trigger={selectedTrigger}
|
||||
selectedCamera={selectedCamera}
|
||||
isLoading={isLoading}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onCancel={() => {
|
||||
setShowCreate(false);
|
||||
setSelectedTrigger(null);
|
||||
setUnsavedChanges(false);
|
||||
}}
|
||||
/>
|
||||
<DeleteTriggerDialog
|
||||
show={showDelete}
|
||||
triggerName={selectedTrigger?.name ?? ""}
|
||||
isLoading={isLoading}
|
||||
onCancel={() => {
|
||||
setShowDelete(false);
|
||||
setSelectedTrigger(null);
|
||||
setUnsavedChanges(false);
|
||||
}}
|
||||
onDelete={() => onDelete(selectedTrigger?.name ?? "")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user