mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-28 04:06:09 -05:00
Compare commits
211 Commits
v0.17.0-be
...
refactor-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bfbb5091a | ||
|
|
ae4b5f015e | ||
|
|
4217414e1d | ||
|
|
4a0469a69c | ||
|
|
232a0655e8 | ||
|
|
98453a5cd9 | ||
|
|
688bef22f2 | ||
|
|
6bceafe686 | ||
|
|
e8d227eb0a | ||
|
|
366365e59f | ||
|
|
c3be785061 | ||
|
|
14d9068bfa | ||
|
|
32b5418ff1 | ||
|
|
7d678de445 | ||
|
|
abb03bd554 | ||
|
|
7df7330eae | ||
|
|
1e061538a1 | ||
|
|
b4462138fb | ||
|
|
65b8a1c201 | ||
|
|
4f358c376f | ||
|
|
7badcbdbeb | ||
|
|
66e65afcda | ||
|
|
aae5250122 | ||
|
|
e9aebbe53f | ||
|
|
9128881924 | ||
|
|
4277834757 | ||
|
|
79fedee1d1 | ||
|
|
58053eb3f0 | ||
|
|
35fd1ccbc0 | ||
|
|
105e7ca4fd | ||
|
|
fa1f9a1fa4 | ||
|
|
e7250f24cb | ||
|
|
eeefbf2bb5 | ||
|
|
ba0e7bbc1a | ||
|
|
e16763cff9 | ||
|
|
b88186983a | ||
|
|
b4eac11cbd | ||
|
|
9c3a74b4f5 | ||
|
|
91714b8743 | ||
|
|
e5087b092d | ||
|
|
5f02e33e55 | ||
|
|
84760c42cb | ||
|
|
bb6e889449 | ||
|
|
12506f8c80 | ||
|
|
fef1fb36cc | ||
|
|
2db0269825 | ||
|
|
a4362caa0a | ||
|
|
fa0feebd03 | ||
|
|
c78ab2dc87 | ||
|
|
e76b48f98b | ||
|
|
af2339b35c | ||
|
|
9b7cee18db | ||
|
|
d3260e34b6 | ||
|
|
ee2c96c793 | ||
|
|
542295dcb3 | ||
|
|
56c7a13fbe | ||
|
|
88348bf535 | ||
|
|
b66e69efc9 | ||
|
|
63e7bf8b28 | ||
|
|
39ad565f81 | ||
|
|
9ef8b70208 | ||
|
|
6b77952b72 | ||
|
|
3745f5ff93 | ||
|
|
3297cab347 | ||
|
|
fc3545310c | ||
|
|
dde738cfdc | ||
|
|
004bb7d80d | ||
|
|
85feb4edcb | ||
|
|
cffa54c80d | ||
|
|
48164f6dfc | ||
|
|
bc457743b6 | ||
|
|
451d6f5c22 | ||
|
|
d24b96d3bb | ||
|
|
7df3622243 | ||
|
|
a0d6cb5c15 | ||
|
|
352d271fe4 | ||
|
|
a6e11a59d6 | ||
|
|
a7d8d13d9a | ||
|
|
4d51f7a1bb | ||
|
|
c9be98f935 | ||
|
|
85ed8c6432 | ||
|
|
f0d69f7856 | ||
|
|
5b16978430 | ||
|
|
b6142e3017 | ||
|
|
e1a6f69c4e | ||
|
|
d940ff3341 | ||
|
|
1f14f1cda0 | ||
|
|
806c5892b6 | ||
|
|
8bc82060f1 | ||
|
|
3cc8311b48 | ||
|
|
71139ef842 | ||
|
|
f4f32a3f59 | ||
|
|
29a4076589 | ||
|
|
d4d4164f99 | ||
|
|
5cc81bc7a1 | ||
|
|
1856e62ad0 | ||
|
|
252f1a6eb9 | ||
|
|
ad076aefff | ||
|
|
8a95cd2472 | ||
|
|
3aeeb09834 | ||
|
|
8c98b4c9d0 | ||
|
|
a2d6e04f45 | ||
|
|
7c11747ab3 | ||
|
|
5f2536dcd8 | ||
|
|
ef5608a970 | ||
|
|
3101d5f27b | ||
|
|
4dcd2968b3 | ||
|
|
73c1e12faf | ||
|
|
5f93cee732 | ||
|
|
67e3f8eefa | ||
|
|
e1005ac2a5 | ||
|
|
6accc38275 | ||
|
|
ff20be58b4 | ||
|
|
fc3f798bd6 | ||
|
|
44e695362a | ||
|
|
9fbc854bf5 | ||
|
|
334acd6078 | ||
|
|
92c503070c | ||
|
|
ecd7d04228 | ||
|
|
11576e9e68 | ||
|
|
2cfb118981 | ||
|
|
e1c273be8d | ||
|
|
ea1533f456 | ||
|
|
41b983a133 | ||
|
|
c9055ea941 | ||
|
|
c9ba851f0d | ||
|
|
a8ab82937b | ||
|
|
21e4b36c7c | ||
|
|
06141b900e | ||
|
|
011e7a1ce6 | ||
|
|
81d5e80dd2 | ||
|
|
3b79b34c07 | ||
|
|
7d8d2c5521 | ||
|
|
7fcce67c2a | ||
|
|
87da12c453 | ||
|
|
522630487b | ||
|
|
77eb5d6012 | ||
|
|
7339961636 | ||
|
|
c34fd3b0d5 | ||
|
|
00c8c407c5 | ||
|
|
b17e5a3eff | ||
|
|
8b3c8a0ade | ||
|
|
2fe5e06fed | ||
|
|
e9db966097 | ||
|
|
4d63a74fd6 | ||
|
|
b6e5894650 | ||
|
|
62b880a4b2 | ||
|
|
5984346623 | ||
|
|
a394d37bfe | ||
|
|
a407f08db6 | ||
|
|
2cd14341e0 | ||
|
|
9285c5e10a | ||
|
|
74ac45d0af | ||
|
|
2f06cfe50c | ||
|
|
9413f1c46d | ||
|
|
fee3050886 | ||
|
|
9d572ba8cb | ||
|
|
9267d006ce | ||
|
|
e6e2b74034 | ||
|
|
0a307edce9 | ||
|
|
59a959430d | ||
|
|
2d83992284 | ||
|
|
e4fe021279 | ||
|
|
b4520d9e2f | ||
|
|
3b6814fbc9 | ||
|
|
338d85a9a2 | ||
|
|
4131252a3b | ||
|
|
50ac5a1483 | ||
|
|
a75f6945ae | ||
|
|
90b14f1a32 | ||
|
|
d633c7d966 | ||
|
|
0a8f499640 | ||
|
|
cfeb86646f | ||
|
|
bf099c3edd | ||
|
|
2e1706baa0 | ||
|
|
43c8f68e44 | ||
|
|
90d857ad6d | ||
|
|
c222aa0e65 | ||
|
|
cd37af4365 | ||
|
|
5e57dbe070 | ||
|
|
bd568ab3b1 | ||
|
|
7d02220ec5 | ||
|
|
da75481443 | ||
|
|
85bc988c76 | ||
|
|
53a592322a | ||
|
|
0a24e3ce67 | ||
|
|
d1a184d4ac | ||
|
|
311549536c | ||
|
|
ace11730bd | ||
|
|
2b345bd3f7 | ||
|
|
e2353e55f3 | ||
|
|
cd3d0883bc | ||
|
|
820fc6e9b5 | ||
|
|
a326ecdc9f | ||
|
|
91f9a01df5 | ||
|
|
6572fa8a48 | ||
|
|
067de06176 | ||
|
|
849677c758 | ||
|
|
1c7f68bf44 | ||
|
|
68fee3ed7b | ||
|
|
c13a47f30b | ||
|
|
8d26d2dca2 | ||
|
|
4188eedf3d | ||
|
|
8a52d83065 | ||
|
|
b2d1fdf7eb | ||
|
|
2c34e1ec10 | ||
|
|
91cc6747b6 | ||
|
|
7b5a1b7284 | ||
|
|
7e5d98dbab | ||
|
|
d952a97bda | ||
|
|
ea39bb3565 |
@@ -229,6 +229,7 @@ Reolink
|
||||
restream
|
||||
restreamed
|
||||
restreaming
|
||||
RJSF
|
||||
rkmpp
|
||||
rknn
|
||||
rkrga
|
||||
|
||||
387
.github/copilot-instructions.md
vendored
387
.github/copilot-instructions.md
vendored
@@ -1,2 +1,385 @@
|
||||
Never write strings in the frontend directly, always write to and reference the relevant translations file.
|
||||
Always conform new and refactored code to the existing coding style in the project.
|
||||
# GitHub Copilot Instructions for Frigate NVR
|
||||
|
||||
This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Frigate NVR is a realtime object detection system for IP cameras that uses:
|
||||
|
||||
- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX
|
||||
- **Frontend**: React with TypeScript, Vite, TailwindCSS
|
||||
- **Architecture**: Multiprocessing design with ZMQ and MQTT communication
|
||||
- **Focus**: Minimal resource usage with maximum performance
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
When reviewing code, do NOT comment on:
|
||||
|
||||
- Missing imports - Static analysis tooling catches these
|
||||
- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting
|
||||
- Minor style inconsistencies already enforced by linters
|
||||
|
||||
## Python Backend Standards
|
||||
|
||||
### Python Requirements
|
||||
|
||||
- **Compatibility**: Python 3.13+
|
||||
- **Language Features**: Use modern Python features:
|
||||
- Pattern matching
|
||||
- Type hints (comprehensive typing preferred)
|
||||
- f-strings (preferred over `%` or `.format()`)
|
||||
- Dataclasses
|
||||
- Async/await patterns
|
||||
|
||||
### Code Quality Standards
|
||||
|
||||
- **Formatting**: Ruff (configured in `pyproject.toml`)
|
||||
- **Linting**: Ruff with rules defined in project config
|
||||
- **Type Checking**: Use type hints consistently
|
||||
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
|
||||
- **Language**: American English for all code, comments, and documentation
|
||||
|
||||
### Logging Standards
|
||||
|
||||
- **Logger Pattern**: Use module-level logger
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
- **Format Guidelines**:
|
||||
- No periods at end of log messages
|
||||
- No sensitive data (keys, tokens, passwords)
|
||||
- Use lazy logging: `logger.debug("Message with %s", variable)`
|
||||
- **Log Levels**:
|
||||
- `debug`: Development and troubleshooting information
|
||||
- `info`: Important runtime events (startup, shutdown, state changes)
|
||||
- `warning`: Recoverable issues that should be addressed
|
||||
- `error`: Errors that affect functionality but don't crash the app
|
||||
- `exception`: Use in except blocks to include traceback
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Exception Types**: Choose most specific exception available
|
||||
- **Try/Catch Best Practices**:
|
||||
- Only wrap code that can throw exceptions
|
||||
- Keep try blocks minimal - process data after the try/except
|
||||
- Avoid bare exceptions except in background tasks
|
||||
|
||||
Bad pattern:
|
||||
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
# ❌ Don't process data inside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
result = processed
|
||||
except DeviceError:
|
||||
logger.error("Failed to get data")
|
||||
```
|
||||
|
||||
Good pattern:
|
||||
|
||||
```python
|
||||
try:
|
||||
data = await device.get_data() # Can throw
|
||||
except DeviceError:
|
||||
logger.error("Failed to get data")
|
||||
return
|
||||
|
||||
# ✅ Process data outside try block
|
||||
processed = data.get("value", 0) * 100
|
||||
result = processed
|
||||
```
|
||||
|
||||
### Async Programming
|
||||
|
||||
- **External I/O**: All external I/O operations must be async
|
||||
- **Best Practices**:
|
||||
- Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()`
|
||||
- Avoid awaiting in loops - use `asyncio.gather()` instead
|
||||
- No blocking calls in async functions
|
||||
- Use `asyncio.create_task()` for background operations
|
||||
- **Thread Safety**: Use proper synchronization for shared state
|
||||
|
||||
### Documentation Standards
|
||||
|
||||
- **Module Docstrings**: Concise descriptions at top of files
|
||||
```python
|
||||
"""Utilities for motion detection and analysis."""
|
||||
```
|
||||
- **Function Docstrings**: Required for public functions and methods
|
||||
|
||||
```python
|
||||
async def process_frame(frame: ndarray, config: Config) -> Detection:
|
||||
"""Process a video frame for object detection.
|
||||
|
||||
Args:
|
||||
frame: The video frame as numpy array
|
||||
config: Detection configuration
|
||||
|
||||
Returns:
|
||||
Detection results with bounding boxes
|
||||
"""
|
||||
```
|
||||
|
||||
- **Comment Style**:
|
||||
- Explain the "why" not just the "what"
|
||||
- Keep lines under 88 characters when possible
|
||||
- Use clear, descriptive comments
|
||||
|
||||
### File Organization
|
||||
|
||||
- **API Endpoints**: `frigate/api/` - FastAPI route handlers
|
||||
- **Configuration**: `frigate/config/` - Configuration parsing and validation
|
||||
- **Detectors**: `frigate/detectors/` - Object detection backends
|
||||
- **Events**: `frigate/events/` - Event management and storage
|
||||
- **Utilities**: `frigate/util/` - Shared utility functions
|
||||
|
||||
## Frontend (React/TypeScript) Standards
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
- **CRITICAL**: Never write user-facing strings directly in components
|
||||
- **Always use react-i18next**: Import and use the `t()` function
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
return <div>{t("camera_not_found")}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en`
|
||||
- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`)
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
||||
- **Formatting**: Prettier with Tailwind CSS plugin
|
||||
- **Type Safety**: TypeScript strict mode enabled
|
||||
- **Testing**: Vitest for unit tests
|
||||
|
||||
### Component Patterns
|
||||
|
||||
- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`)
|
||||
- **Styling**: TailwindCSS with `cn()` utility for class merging
|
||||
- **State Management**: React hooks (useState, useEffect, useCallback, useMemo)
|
||||
- **Data Fetching**: Custom hooks with proper loading and error states
|
||||
|
||||
### ESLint Rules
|
||||
|
||||
Key rules enforced:
|
||||
|
||||
- `react-hooks/rules-of-hooks`: error
|
||||
- `react-hooks/exhaustive-deps`: error
|
||||
- `no-console`: error (use proper logging or remove)
|
||||
- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`)
|
||||
- Unused variables must be prefixed with `_`
|
||||
- Comma dangles required for multiline objects/arrays
|
||||
|
||||
### File Organization
|
||||
|
||||
- **Pages**: `web/src/pages/` - Route components
|
||||
- **Views**: `web/src/views/` - Complex view components
|
||||
- **Components**: `web/src/components/` - Reusable components
|
||||
- **Hooks**: `web/src/hooks/` - Custom React hooks
|
||||
- **API**: `web/src/api/` - API client functions
|
||||
- **Types**: `web/src/types/` - TypeScript type definitions
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Backend Testing
|
||||
|
||||
- **Framework**: Python unittest
|
||||
- **Run Command**: `python3 -u -m unittest`
|
||||
- **Location**: `frigate/test/`
|
||||
- **Coverage**: Aim for comprehensive test coverage of core functionality
|
||||
- **Pattern**: Use `TestCase` classes with descriptive test method names
|
||||
```python
|
||||
class TestMotionDetection(unittest.TestCase):
|
||||
def test_detects_motion_above_threshold(self):
|
||||
# Test implementation
|
||||
```
|
||||
|
||||
### Test Best Practices
|
||||
|
||||
- Always have a way to test your work and confirm your changes
|
||||
- Write tests for bug fixes to prevent regressions
|
||||
- Test edge cases and error conditions
|
||||
- Mock external dependencies (cameras, APIs, hardware)
|
||||
- Use fixtures for test data
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Python Backend
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python3 -u -m unittest
|
||||
|
||||
# Run specific test file
|
||||
python3 -u -m unittest frigate.test.test_ffmpeg_presets
|
||||
|
||||
# Check formatting (Ruff)
|
||||
ruff format --check frigate/
|
||||
|
||||
# Apply formatting
|
||||
ruff format frigate/
|
||||
|
||||
# Run linter
|
||||
ruff check frigate/
|
||||
```
|
||||
|
||||
### Frontend (from web/ directory)
|
||||
|
||||
```bash
|
||||
# Start dev server (AI agents should never run this directly unless asked)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Fix linting issues
|
||||
npm run lint:fix
|
||||
|
||||
# Format code
|
||||
npm run prettier:write
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
|
||||
AI agents should never run these commands directly unless instructed.
|
||||
|
||||
```bash
|
||||
# Build local image
|
||||
make local
|
||||
|
||||
# Build debug image
|
||||
make debug
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### API Endpoint Pattern
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Request
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
router = APIRouter(tags=[Tags.Events])
|
||||
|
||||
@router.get("/events")
|
||||
async def get_events(request: Request, limit: int = 100):
|
||||
"""Retrieve events from the database."""
|
||||
# Implementation
|
||||
```
|
||||
|
||||
### Configuration Access
|
||||
|
||||
```python
|
||||
# Access Frigate configuration
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
camera_config = config.cameras["front_door"]
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
|
||||
```python
|
||||
from frigate.models import Event
|
||||
|
||||
# Use Peewee ORM for database access
|
||||
events = (
|
||||
Event.select()
|
||||
.where(Event.camera == camera_name)
|
||||
.order_by(Event.start_time.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
```
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Avoid These
|
||||
|
||||
```python
|
||||
# Blocking operations in async functions
|
||||
data = requests.get(url) # ❌ Use async HTTP client
|
||||
time.sleep(5) # ❌ Use asyncio.sleep()
|
||||
|
||||
# Hardcoded strings in React components
|
||||
<div>Camera not found</div> # ❌ Use t("camera_not_found")
|
||||
|
||||
# Missing error handling
|
||||
data = await api.get_data() # ❌ No exception handling
|
||||
|
||||
# Bare exceptions in regular code
|
||||
try:
|
||||
value = await sensor.read()
|
||||
except Exception: # ❌ Too broad
|
||||
logger.error("Failed")
|
||||
```
|
||||
|
||||
### ✅ Use These Instead
|
||||
|
||||
```python
|
||||
# Async operations
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
data = await response.json()
|
||||
|
||||
await asyncio.sleep(5) # ✅ Non-blocking
|
||||
|
||||
# Translatable strings in React
|
||||
const { t } = useTranslation();
|
||||
<div>{t("camera_not_found")}</div> # ✅ Translatable
|
||||
|
||||
# Proper error handling
|
||||
try:
|
||||
data = await api.get_data()
|
||||
except ApiException as err:
|
||||
logger.error("API error: %s", err)
|
||||
raise
|
||||
|
||||
# Specific exceptions
|
||||
try:
|
||||
value = await sensor.read()
|
||||
except SensorException as err: # ✅ Specific
|
||||
logger.exception("Failed to read sensor")
|
||||
```
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- Main config: `config/config.yml`
|
||||
|
||||
### Directory Structure
|
||||
|
||||
- Backend code: `frigate/`
|
||||
- Frontend code: `web/`
|
||||
- Docker files: `docker/`
|
||||
- Documentation: `docs/`
|
||||
- Database migrations: `migrations/`
|
||||
|
||||
### Code Style Conformance
|
||||
|
||||
Always conform new and refactored code to the existing coding style in the project:
|
||||
|
||||
- Follow established patterns in similar files
|
||||
- Match indentation and formatting of surrounding code
|
||||
- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript)
|
||||
- Maintain the same level of verbosity in comments and docstrings
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- Documentation: https://docs.frigate.video
|
||||
- Main Repository: https://github.com/blakeblackshear/frigate
|
||||
- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration
|
||||
|
||||
5
Makefile
5
Makefile
@@ -1,7 +1,7 @@
|
||||
default_target: local
|
||||
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
VERSION = 0.17.0
|
||||
VERSION = 0.18.0
|
||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BOARDS= #Initialized empty
|
||||
@@ -49,7 +49,8 @@ push: push-boards
|
||||
--push
|
||||
|
||||
run: local
|
||||
docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest
|
||||
docker run --rm --publish=5000:5000 --publish=8971:8971 \
|
||||
--volume=${PWD}/config:/config frigate:latest
|
||||
|
||||
run_tests: local
|
||||
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
# Update package list and install dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake git wget
|
||||
sudo apt-get install -y build-essential cmake git wget linux-headers-$(uname -r)
|
||||
|
||||
hailo_version="4.21.0"
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ $arch == "x86_64" ]]; then
|
||||
sudo apt install -y linux-headers-$(uname -r);
|
||||
else
|
||||
sudo apt install -y linux-modules-extra-$(uname -r);
|
||||
if [[ $arch == "aarch64" ]]; then
|
||||
source /etc/os-release
|
||||
os_codename=$VERSION_CODENAME
|
||||
echo "Detected OS codename: $os_codename"
|
||||
fi
|
||||
|
||||
if [ "$os_codename" = "trixie" ]; then
|
||||
sudo apt install -y dkms
|
||||
fi
|
||||
|
||||
# Clone the HailoRT driver repository
|
||||
@@ -47,3 +51,4 @@ sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
|
||||
echo "HailoRT driver installation complete."
|
||||
echo "reboot your system to load the firmware!"
|
||||
echo "Driver version: $(modinfo -F version hailo_pci)"
|
||||
|
||||
@@ -55,7 +55,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.10/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.13/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
|
||||
FROM wget AS tempio
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -47,7 +47,7 @@ onnxruntime == 1.22.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
# Generative AI
|
||||
google-generativeai == 0.8.*
|
||||
google-genai == 1.58.*
|
||||
ollama == 0.6.*
|
||||
openai == 1.65.*
|
||||
# push notifications
|
||||
|
||||
@@ -10,7 +10,8 @@ echo "[INFO] Starting certsync..."
|
||||
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
|
||||
tls_enabled=`python3 /usr/local/nginx/get_listen_settings.py | jq -r .tls.enabled`
|
||||
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
|
||||
while true
|
||||
do
|
||||
@@ -34,7 +35,7 @@ do
|
||||
;;
|
||||
esac
|
||||
|
||||
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:8971 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
|
||||
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
|
||||
|
||||
case "$liveprint" in
|
||||
*Fingerprint*)
|
||||
@@ -55,4 +56,4 @@ do
|
||||
|
||||
done
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
|
||||
@@ -54,8 +54,8 @@ function setup_homekit_config() {
|
||||
local config_path="$1"
|
||||
|
||||
if [[ ! -f "${config_path}" ]]; then
|
||||
echo "[INFO] Creating empty HomeKit config file..."
|
||||
echo 'homekit: {}' > "${config_path}"
|
||||
echo "[INFO] Creating empty config file for HomeKit..."
|
||||
echo '{}' > "${config_path}"
|
||||
fi
|
||||
|
||||
# Convert YAML to JSON for jq processing
|
||||
@@ -69,15 +69,15 @@ function setup_homekit_config() {
|
||||
local cleaned_json="/tmp/cache/homekit_cleaned.json"
|
||||
jq '
|
||||
# Keep only the homekit section if it exists, otherwise empty object
|
||||
if has("homekit") then {homekit: .homekit} else {homekit: {}} end
|
||||
if has("homekit") then {homekit: .homekit} else {} end
|
||||
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
|
||||
echo '{"homekit": {}}' > "${cleaned_json}"
|
||||
echo '{}' > "${cleaned_json}"
|
||||
}
|
||||
|
||||
# Convert back to YAML and write to the config file
|
||||
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
|
||||
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
||||
echo 'homekit: {}' > "${config_path}"
|
||||
echo '{}' > "${config_path}"
|
||||
}
|
||||
|
||||
# Clean up temp files
|
||||
|
||||
@@ -80,14 +80,14 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
|
||||
fi
|
||||
|
||||
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||
python3 /usr/local/nginx/get_base_path.py | \
|
||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
||||
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
||||
-out /usr/local/nginx/conf/base_path.conf
|
||||
-out /usr/local/nginx/conf/base_path.conf
|
||||
|
||||
# build templates for optional TLS support
|
||||
python3 /usr/local/nginx/get_listen_settings.py | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /usr/local/nginx/conf/listen.conf
|
||||
# build templates for additional network settings
|
||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /usr/local/nginx/conf/listen.conf
|
||||
|
||||
# Replace the bash process with the NGINX process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
|
||||
@@ -23,8 +23,28 @@ sys.path.remove("/opt/frigate")
|
||||
yaml = YAML()
|
||||
|
||||
# Check if arbitrary exec sources are allowed (defaults to False for security)
|
||||
ALLOW_ARBITRARY_EXEC = os.environ.get(
|
||||
"GO2RTC_ALLOW_ARBITRARY_EXEC", "false"
|
||||
allow_arbitrary_exec = None
|
||||
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||
elif (
|
||||
os.path.isdir("/run/secrets")
|
||||
and os.access("/run/secrets", os.R_OK)
|
||||
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||
):
|
||||
allow_arbitrary_exec = (
|
||||
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
|
||||
.read_text()
|
||||
.strip()
|
||||
)
|
||||
# check for the add-on options file
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
with open("/data/options.json") as f:
|
||||
raw_options = f.read()
|
||||
options = json.loads(raw_options)
|
||||
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
|
||||
|
||||
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
|
||||
allow_arbitrary_exec
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Prints the base path as json to stdout."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||
|
||||
result: dict[str, Any] = {"base_path": base_path}
|
||||
|
||||
print(json.dumps(result))
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Prints the tls config as json to stdout."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
tls_config: dict[str, any] = config.get("tls", {"enabled": True})
|
||||
networking_config = config.get("networking", {})
|
||||
ipv6_config = networking_config.get("ipv6", {"enabled": False})
|
||||
|
||||
output = {"tls": tls_config, "ipv6": ipv6_config}
|
||||
|
||||
print(json.dumps(output))
|
||||
62
docker/main/rootfs/usr/local/nginx/get_nginx_settings.py
Normal file
62
docker/main/rootfs/usr/local/nginx/get_nginx_settings.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Prints the nginx settings as json to stdout."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
sys.path.insert(0, "/opt/frigate")
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith((".yaml", ".yml")):
|
||||
config: dict[str, Any] = yaml.load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config: dict[str, Any] = json.loads(raw_config)
|
||||
except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
tls_config: dict[str, Any] = config.get("tls", {})
|
||||
tls_config.setdefault("enabled", True)
|
||||
|
||||
networking_config: dict[str, Any] = config.get("networking", {})
|
||||
ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
|
||||
ipv6_config.setdefault("enabled", False)
|
||||
|
||||
listen_config: dict[str, Any] = networking_config.get("listen", {})
|
||||
listen_config.setdefault("internal", 5000)
|
||||
listen_config.setdefault("external", 8971)
|
||||
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = listen_config["internal"]
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
listen_config["internal_port"] = internal_port
|
||||
|
||||
# handle case where external port is a string with ip:port
|
||||
external_port = listen_config["external"]
|
||||
if type(external_port) is str:
|
||||
external_port = int(external_port.split(":")[-1])
|
||||
listen_config["external_port"] = external_port
|
||||
|
||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"tls": tls_config,
|
||||
"ipv6": ipv6_config,
|
||||
"listen": listen_config,
|
||||
"base_path": base_path,
|
||||
}
|
||||
|
||||
print(json.dumps(result))
|
||||
@@ -7,7 +7,7 @@ location ^~ {{ .base_path }}/ {
|
||||
# remove base_url from the path before passing upstream
|
||||
rewrite ^{{ .base_path }}/(.*) /$1 break;
|
||||
|
||||
proxy_pass $scheme://127.0.0.1:8971;
|
||||
proxy_pass $scheme://127.0.0.1:{{ .listen.external_port }};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
|
||||
# Internal (IPv4 always; IPv6 optional)
|
||||
listen 5000;
|
||||
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:5000;{{ end }}{{ end }}
|
||||
|
||||
listen {{ .listen.internal }};
|
||||
{{ if .ipv6.enabled }}listen [::]:{{ .listen.internal_port }};{{ end }}
|
||||
|
||||
# intended for external traffic, protected by auth
|
||||
{{ if .tls }}
|
||||
{{ if .tls.enabled }}
|
||||
# external HTTPS (IPv4 always; IPv6 optional)
|
||||
listen 8971 ssl;
|
||||
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971 ssl;{{ end }}{{ end }}
|
||||
{{ if .tls.enabled }}
|
||||
# external HTTPS (IPv4 always; IPv6 optional)
|
||||
listen {{ .listen.external }} ssl;
|
||||
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }}
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
|
||||
|
||||
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
|
||||
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
||||
ssl_session_tickets off;
|
||||
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
|
||||
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
|
||||
ssl_session_tickets off;
|
||||
|
||||
# modern configuration
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
# modern configuration
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# ACME challenge location
|
||||
location /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
root /etc/letsencrypt/www;
|
||||
}
|
||||
{{ else }}
|
||||
# external HTTP (IPv4 always; IPv6 optional)
|
||||
listen 8971;
|
||||
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }}
|
||||
{{ end }}
|
||||
# ACME challenge location
|
||||
location /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
root /etc/letsencrypt/www;
|
||||
}
|
||||
{{ else }}
|
||||
# (No tls section) default to HTTP (IPv4 always; IPv6 optional)
|
||||
listen 8971;
|
||||
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }}
|
||||
# (No tls) default to HTTP (IPv4 always; IPv6 optional)
|
||||
listen {{ .listen.external }};
|
||||
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }};{{ end }}
|
||||
{{ end }}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG ROCM
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
@@ -56,6 +56,8 @@ FROM scratch AS rocm-dist
|
||||
|
||||
ARG ROCM
|
||||
|
||||
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
|
||||
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
|
||||
# Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3)
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
|
||||
|
||||
@@ -1 +1 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.1.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
@@ -1,5 +1,5 @@
|
||||
variable "ROCM" {
|
||||
default = "7.1.1"
|
||||
default = "7.2.0"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@@ -155,34 +155,33 @@ services:
|
||||
|
||||
### Enabling IPv6
|
||||
|
||||
IPv6 is disabled by default, to enable IPv6 listen.gotmpl needs to be bind mounted with IPv6 enabled. For example:
|
||||
IPv6 is disabled by default, to enable IPv6 modify your Frigate configuration as follows:
|
||||
|
||||
```
|
||||
{{ if not .enabled }}
|
||||
# intended for external traffic, protected by auth
|
||||
listen 8971;
|
||||
{{ else }}
|
||||
# intended for external traffic, protected by auth
|
||||
listen 8971 ssl;
|
||||
|
||||
# intended for internal traffic, not protected by auth
|
||||
listen 5000;
|
||||
```yaml
|
||||
networking:
|
||||
ipv6:
|
||||
enabled: True
|
||||
```
|
||||
|
||||
becomes
|
||||
### Listen on different ports
|
||||
|
||||
```
|
||||
{{ if not .enabled }}
|
||||
# intended for external traffic, protected by auth
|
||||
listen [::]:8971 ipv6only=off;
|
||||
{{ else }}
|
||||
# intended for external traffic, protected by auth
|
||||
listen [::]:8971 ipv6only=off ssl;
|
||||
You can change the ports Nginx uses for listening using Frigate's configuration file. The internal port (unauthenticated) and external port (authenticated) can be changed independently. You can also specify an IP address using the format `ip:port` if you wish to bind the port to a specific interface. This may be useful for example to prevent exposing the internal port outside the container.
|
||||
|
||||
# intended for internal traffic, not protected by auth
|
||||
listen [::]:5000 ipv6only=off;
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
networking:
|
||||
listen:
|
||||
internal: 127.0.0.1:5000
|
||||
external: 8971
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
||||
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations.
|
||||
|
||||
:::
|
||||
|
||||
## Base path
|
||||
|
||||
By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing.
|
||||
@@ -234,7 +233,7 @@ To do this:
|
||||
|
||||
### Custom go2rtc version
|
||||
|
||||
Frigate currently includes go2rtc v1.9.10, there may be certain cases where you want to run a different version of go2rtc.
|
||||
Frigate currently includes go2rtc v1.9.13, there may be certain cases where you want to run a different version of go2rtc.
|
||||
|
||||
To do this:
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ auth:
|
||||
reset_admin_password: true
|
||||
```
|
||||
|
||||
## Password guidance
|
||||
|
||||
Constructing secure passwords and managing them properly is important. Frigate requires a minimum length of 12 characters. For guidance on password standards see [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). To learn what makes a password truly secure, read this [article](https://medium.com/peerio/how-to-build-a-billion-dollar-password-3d92568d9277).
|
||||
|
||||
## Login failure rate limiting
|
||||
|
||||
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples).
|
||||
@@ -162,6 +166,10 @@ In this example:
|
||||
- If no mapping matches, Frigate falls back to `default_role` if configured.
|
||||
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
|
||||
|
||||
**Note on matching semantics:**
|
||||
|
||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -244,7 +244,7 @@ go2rtc:
|
||||
- rtspx://192.168.1.1:7441/abcdefghijk
|
||||
```
|
||||
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-rtsp)
|
||||
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-rtsp)
|
||||
|
||||
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect.
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ cameras:
|
||||
|
||||
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
|
||||
|
||||
:::note
|
||||
|
||||
Some cameras use a separate ONVIF/service account that is distinct from the device administrator credentials. If ONVIF authentication fails with the admin account, try creating or using an ONVIF/service user in the camera's firmware. Refer to your camera manufacturer's documentation for more.
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
If your ONVIF camera does not require authentication credentials, you may still need to specify an empty string for `user` and `password`, eg: `user: ""` and `password: ""`.
|
||||
@@ -95,7 +101,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
|
||||
|
||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
---
|
||||
id: genai
|
||||
title: Generative AI
|
||||
---
|
||||
|
||||
Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail.
|
||||
|
||||
Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response.
|
||||
|
||||
## Configuration
|
||||
|
||||
Generative AI can be enabled for all cameras or only for specific cameras. If GenAI is disabled for a camera, you can still manually generate descriptions for events using the HTTP API. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below.
|
||||
|
||||
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-2.0-flash
|
||||
|
||||
cameras:
|
||||
front_camera:
|
||||
genai:
|
||||
enabled: True # <- enable GenAI for your front camera
|
||||
use_snapshot: True
|
||||
objects:
|
||||
- person
|
||||
required_zones:
|
||||
- steps
|
||||
indoor_camera:
|
||||
objects:
|
||||
genai:
|
||||
enabled: False # <- disable GenAI for your indoor camera
|
||||
```
|
||||
|
||||
By default, descriptions will be generated for all tracked objects and all zones. But you can also optionally specify `objects` and `required_zones` to only generate descriptions for certain tracked objects or zones.
|
||||
|
||||
Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction.
|
||||
|
||||
Generative AI can also be toggled dynamically for a camera via MQTT with the topic `frigate/<camera_name>/object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset).
|
||||
|
||||
## Ollama
|
||||
|
||||
:::warning
|
||||
|
||||
Using Ollama on CPU is not recommended, high inference times make using Generative AI impractical.
|
||||
|
||||
:::
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
|
||||
Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available.
|
||||
|
||||
Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests).
|
||||
|
||||
### Model Types: Instruct vs Thinking
|
||||
|
||||
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
|
||||
|
||||
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
|
||||
- **Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
|
||||
|
||||
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, Frigate will always use instruct-style prompts and specifically disables thinking-mode behaviors to ensure concise, useful responses.
|
||||
|
||||
**Recommendation:**
|
||||
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider’s documentation or model library for guidance on the correct model variant to use.
|
||||
|
||||
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/search?c=vision). Note that Frigate will not automatically download the model you specify in your config, you must download the model to your local instance of Ollama first i.e. by running `ollama pull qwen3-vl:2b-instruct` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag.
|
||||
|
||||
:::note
|
||||
|
||||
You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 32 GB to run the 33B models.
|
||||
|
||||
:::
|
||||
|
||||
#### Ollama Cloud models
|
||||
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:4b
|
||||
```
|
||||
|
||||
## Google Gemini
|
||||
|
||||
Google Gemini has a free tier allowing [15 queries per minute](https://ai.google.dev/pricing) to the API, which is more than sufficient for standard Frigate usage.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini).
|
||||
|
||||
### Get API Key
|
||||
|
||||
To start using Gemini, you must first get an API key from [Google AI Studio](https://aistudio.google.com).
|
||||
|
||||
1. Accept the Terms of Service
|
||||
2. Click "Get API Key" from the right hand navigation
|
||||
3. Click "Create API key in new project"
|
||||
4. Copy the API key for use in your config
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-2.0-flash
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
To use a different Gemini-compatible API endpoint, set the `GEMINI_BASE_URL` environment variable to your provider's API URL.
|
||||
|
||||
:::
|
||||
|
||||
## OpenAI
|
||||
|
||||
OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models).
|
||||
|
||||
### Get API Key
|
||||
|
||||
To start using OpenAI, you must first [create an API key](https://platform.openai.com/api-keys) and [configure billing](https://platform.openai.com/settings/organization/billing/overview).
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: openai
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
model: gpt-4o
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL.
|
||||
|
||||
:::
|
||||
|
||||
## Azure OpenAI
|
||||
|
||||
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models).
|
||||
|
||||
### Create Resource and Get API Key
|
||||
|
||||
To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key, model name, and resource URL, which must include the `api-version` parameter (see the example below).
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: azure_openai
|
||||
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
|
||||
model: gpt-5-mini
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
```
|
||||
|
||||
## Usage and Best Practices
|
||||
|
||||
Frigate's thumbnail search excels at identifying specific details about tracked objects – for example, using an "image caption" approach to find a "person wearing a yellow vest," "a white dog running across the lawn," or "a red car on a residential street." To enhance this further, Frigate’s default prompts are designed to ask your AI provider about the intent behind the object's actions, rather than just describing its appearance.
|
||||
|
||||
While generating simple descriptions of detected objects is useful, understanding intent provides a deeper layer of insight. Instead of just recognizing "what" is in a scene, Frigate’s default prompts aim to infer "why" it might be there or "what" it could do next. Descriptions tell you what’s happening, but intent gives context. For instance, a person walking toward a door might seem like a visitor, but if they’re moving quickly after hours, you can infer a potential break-in attempt. Detecting a person loitering near a door at night can trigger an alert sooner than simply noting "a person standing by the door," helping you respond based on the situation’s context.
|
||||
|
||||
### Using GenAI for notifications
|
||||
|
||||
Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/<event_id>`.
|
||||
|
||||
If looking to get notifications earlier than when an object ceases to be tracked, an additional send trigger can be configured of `after_significant_updates`.
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
send_triggers:
|
||||
tracked_object_end: true # default
|
||||
after_significant_updates: 3 # how many updates to a tracked object before we should send an image
|
||||
```
|
||||
|
||||
## Custom Prompts
|
||||
|
||||
Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows:
|
||||
|
||||
```
|
||||
Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
||||
Prompts can use variable replacements `{label}`, `{sub_label}`, and `{camera}` to substitute information from the tracked object as part of the prompt.
|
||||
|
||||
:::
|
||||
|
||||
You are also able to define custom prompts in your configuration.
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: qwen3-vl:8b-instruct
|
||||
|
||||
objects:
|
||||
prompt: "Analyze the {label} in these images from the {camera} security camera. Focus on the actions, behavior, and potential intent of the {label}, rather than just describing its appearance."
|
||||
object_prompts:
|
||||
person: "Examine the main person in these images. What are they doing and what might their actions suggest about their intent (e.g., approaching a door, leaving an area, standing still)? Do not describe the surroundings or static details."
|
||||
car: "Observe the primary vehicle in these images. Focus on its movement, direction, or purpose (e.g., parking, approaching, circling). If it's a delivery vehicle, mention the company."
|
||||
```
|
||||
|
||||
Prompts can also be overridden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
objects:
|
||||
genai:
|
||||
enabled: True
|
||||
use_snapshot: True
|
||||
prompt: "Analyze the {label} in these images from the {camera} security camera at the front door. Focus on the actions and potential intent of the {label}."
|
||||
object_prompts:
|
||||
person: "Examine the person in these images. What are they doing, and how might their actions suggest their purpose (e.g., delivering something, approaching, leaving)? If they are carrying or interacting with a package, include details about its source or destination."
|
||||
cat: "Observe the cat in these images. Focus on its movement and intent (e.g., wandering, hunting, interacting with objects). If the cat is near the flower pots or engaging in any specific actions, mention it."
|
||||
objects:
|
||||
- person
|
||||
- cat
|
||||
required_zones:
|
||||
- steps
|
||||
```
|
||||
|
||||
### Experiment with prompts
|
||||
|
||||
Many providers also have a public facing chat interface for their models. Download a couple of different thumbnails or snapshots from Frigate and try new things in the playground to get descriptions to your liking before updating the prompt in Frigate.
|
||||
|
||||
- OpenAI - [ChatGPT](https://chatgpt.com)
|
||||
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
@@ -5,7 +5,7 @@ title: Configuring Generative AI
|
||||
|
||||
## Configuration
|
||||
|
||||
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below.
|
||||
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below.
|
||||
|
||||
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
|
||||
|
||||
@@ -17,11 +17,23 @@ Using Ollama on CPU is not recommended, high inference times make using Generati
|
||||
|
||||
:::
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It provides a nice API over [llama.cpp](https://github.com/ggerganov/llama.cpp). It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
|
||||
Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available.
|
||||
|
||||
Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-does-ollama-handle-concurrent-requests).
|
||||
Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests).
|
||||
|
||||
### Model Types: Instruct vs Thinking
|
||||
|
||||
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
|
||||
|
||||
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
|
||||
- **Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
|
||||
|
||||
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, Frigate will always use instruct-style prompts and specifically disables thinking-mode behaviors to ensure concise, useful responses.
|
||||
|
||||
**Recommendation:**
|
||||
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider’s documentation or model library for guidance on the correct model variant to use.
|
||||
|
||||
### Supported Models
|
||||
|
||||
@@ -41,12 +53,12 @@ If you are trying to use a single model for Frigate and HomeAssistant, it will n
|
||||
|
||||
The following models are recommended:
|
||||
|
||||
| Model | Notes |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
|
||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
||||
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
||||
| Model | Notes |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
|
||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
||||
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
||||
|
||||
:::note
|
||||
|
||||
@@ -54,26 +66,64 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
|
||||
|
||||
:::
|
||||
|
||||
#### Ollama Cloud models
|
||||
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: http://localhost:11434
|
||||
model: minicpm-v:8b
|
||||
provider_options: # other Ollama client options can be defined
|
||||
model: qwen3-vl:4b
|
||||
provider_options: # other Ollama client options can be defined
|
||||
keep_alive: -1
|
||||
options:
|
||||
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
||||
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
||||
```
|
||||
|
||||
## Google Gemini
|
||||
## llama.cpp
|
||||
|
||||
Google Gemini has a free tier allowing [15 queries per minute](https://ai.google.dev/pricing) to the API, which is more than sufficient for standard Frigate usage.
|
||||
[llama.cpp](https://github.com/ggml-org/llama.cpp) is a C++ implementation of LLaMA that provides a high-performance inference server. Using llama.cpp directly gives you access to all native llama.cpp options and parameters.
|
||||
|
||||
:::warning
|
||||
|
||||
Using llama.cpp on CPU is not recommended, high inference times make using Generative AI impractical.
|
||||
|
||||
:::
|
||||
|
||||
It is highly recommended to host the llama.cpp server on a machine with a discrete graphics card, or on an Apple silicon Mac for best performance.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). At the time of writing, this includes `gemini-1.5-pro` and `gemini-1.5-flash`.
|
||||
You must use a vision capable model with Frigate. The llama.cpp server supports various vision models in GGUF format.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: llamacpp
|
||||
base_url: http://localhost:8080
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
temperature: 0.7
|
||||
repeat_penalty: 1.05
|
||||
top_p: 0.8
|
||||
top_k: 40
|
||||
min_p: 0.05
|
||||
seed: -1
|
||||
```
|
||||
|
||||
All llama.cpp native options can be passed through `provider_options`, including `temperature`, `top_k`, `top_p`, `min_p`, `repeat_penalty`, `repeat_last_n`, `seed`, `grammar`, and more. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for a complete list of available parameters.
|
||||
|
||||
## Google Gemini
|
||||
|
||||
Google Gemini has a [free tier](https://ai.google.dev/pricing) for the API, however the limits may not be sufficient for standard Frigate usage. Choose a plan appropriate for your installation.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini).
|
||||
|
||||
### Get API Key
|
||||
|
||||
@@ -90,16 +140,32 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
|
||||
genai:
|
||||
provider: gemini
|
||||
api_key: "{FRIGATE_GEMINI_API_KEY}"
|
||||
model: gemini-1.5-flash
|
||||
model: gemini-2.5-flash
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
|
||||
|
||||
```
|
||||
genai:
|
||||
provider: gemini
|
||||
...
|
||||
provider_options:
|
||||
base_url: https://...
|
||||
```
|
||||
|
||||
Other HTTP options are available, see the [python-genai documentation](https://github.com/googleapis/python-genai).
|
||||
|
||||
:::
|
||||
|
||||
## OpenAI
|
||||
|
||||
OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models). At the time of writing, this includes `gpt-4o` and `gpt-4-turbo`.
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models).
|
||||
|
||||
### Get API Key
|
||||
|
||||
@@ -120,23 +186,41 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
model: your-model-name
|
||||
provider_options:
|
||||
context_size: 8192 # Specify the configured context size
|
||||
```
|
||||
|
||||
This ensures Frigate uses the correct context window size when generating prompts.
|
||||
|
||||
:::
|
||||
|
||||
## Azure OpenAI
|
||||
|
||||
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
|
||||
|
||||
### Supported Models
|
||||
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models). At the time of writing, this includes `gpt-4o` and `gpt-4-turbo`.
|
||||
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models).
|
||||
|
||||
### Create Resource and Get API Key
|
||||
|
||||
To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key and resource URL, which must include the `api-version` parameter (see the example below). The model field is not required in your configuration as the model is part of the deployment name you chose when deploying the resource.
|
||||
To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key, model name, and resource URL, which must include the `api-version` parameter (see the example below).
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: azure_openai
|
||||
base_url: https://example-endpoint.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2023-03-15-preview
|
||||
base_url: https://instance.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview
|
||||
model: gpt-5-mini
|
||||
api_key: "{FRIGATE_OPENAI_API_KEY}"
|
||||
```
|
||||
```
|
||||
@@ -11,7 +11,7 @@ By default, descriptions will be generated for all tracked objects and all zones
|
||||
|
||||
Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction.
|
||||
|
||||
Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate/<camera_name>/object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset).
|
||||
Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate/<camera_name>/object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt#frigatecamera_nameobject_descriptionsset).
|
||||
|
||||
## Usage and Best Practices
|
||||
|
||||
@@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo
|
||||
|
||||
- OpenAI - [ChatGPT](https://chatgpt.com)
|
||||
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
@@ -7,7 +7,7 @@ Generative AI can be used to automatically generate structured summaries of revi
|
||||
|
||||
Requests for a summary are requested automatically to your AI provider for alert review items when the activity has ended, they can also be optionally enabled for detections as well.
|
||||
|
||||
Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt/#frigatecamera_namereviewdescriptionsset).
|
||||
Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt#frigatecamera_namereview_descriptionsset).
|
||||
|
||||
## Review Summary Usage and Best Practices
|
||||
|
||||
@@ -125,10 +125,10 @@ review:
|
||||
|
||||
## Review Reports
|
||||
|
||||
Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review.
|
||||
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
|
||||
|
||||
### Requesting Reports Programmatically
|
||||
|
||||
Review reports can be requested via the [API](/integrations/api#review-summarization) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
|
||||
Review reports can be requested via the [API](/integrations/api/generate-review-summary-review-summarize-start-start-ts-end-end-ts-post) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
|
||||
|
||||
For Home Assistant users, there is a built-in service (`frigate.review_summarize`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.
|
||||
|
||||
@@ -12,23 +12,20 @@ Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accel
|
||||
Object detection and enrichments (like Semantic Search, Face Recognition, and License Plate Recognition) are independent features. To use a GPU / NPU for object detection, see the [Object Detectors](/configuration/object_detectors.md) documentation. If you want to use your GPU for any supported enrichments, you must choose the appropriate Frigate Docker image for your GPU / NPU and configure the enrichment according to its specific documentation.
|
||||
|
||||
- **AMD**
|
||||
|
||||
- ROCm support in the `-rocm` Frigate image is automatically detected for enrichments, but only some enrichment models are available due to ROCm's focus on LLMs and limited stability with certain neural network models. Frigate disables models that perform poorly or are unstable to ensure reliable operation, so only compatible enrichments may be active.
|
||||
|
||||
- **Intel**
|
||||
|
||||
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
|
||||
- **Note:** Intel NPUs have limited model support for enrichments. GPU is recommended for enrichments when available.
|
||||
|
||||
- **Nvidia**
|
||||
|
||||
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
- **RockChip**
|
||||
- RockChip NPU will automatically be detected and used for semantic search v1 and face recognition in the `-rk` Frigate image.
|
||||
|
||||
Utilizing a GPU for enrichments does not require you to use the same GPU for object detection. For example, you can run the `tensorrt` Docker image for enrichments and still use other dedicated hardware like a Coral or Hailo for object detection. However, one combination that is not supported is TensorRT for object detection and OpenVINO for enrichments.
|
||||
Utilizing a GPU for enrichments does not require you to use the same GPU for object detection. For example, you can run the `tensorrt` Docker image to run enrichments on an Nvidia GPU and still use other dedicated hardware like a Coral or Hailo for object detection. However, one combination that is not supported is the `tensorrt` image for object detection on an Nvidia GPU and Intel iGPU for enrichments.
|
||||
|
||||
:::note
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ cameras:
|
||||
|
||||
When running Frigate through the HA Add-on, the Frigate `/config` directory is mapped to `/addon_configs/<addon_directory>` in the host, where `<addon_directory>` is specific to the variant of the Frigate Add-on you are running.
|
||||
|
||||
| Add-on Variant | Configuration directory |
|
||||
| -------------------------- | -------------------------------------------- |
|
||||
| Frigate | `/addon_configs/ccab4aaf_frigate` |
|
||||
| Frigate (Full Access) | `/addon_configs/ccab4aaf_frigate-fa` |
|
||||
| Frigate Beta | `/addon_configs/ccab4aaf_frigate-beta` |
|
||||
| Frigate Beta (Full Access) | `/addon_configs/ccab4aaf_frigate-fa-beta` |
|
||||
| Add-on Variant | Configuration directory |
|
||||
| -------------------------- | ----------------------------------------- |
|
||||
| Frigate | `/addon_configs/ccab4aaf_frigate` |
|
||||
| Frigate (Full Access) | `/addon_configs/ccab4aaf_frigate-fa` |
|
||||
| Frigate Beta | `/addon_configs/ccab4aaf_frigate-beta` |
|
||||
| Frigate Beta (Full Access) | `/addon_configs/ccab4aaf_frigate-fa-beta` |
|
||||
|
||||
**Whenever you see `/config` in the documentation, it refers to this directory.**
|
||||
|
||||
@@ -109,15 +109,16 @@ detectors:
|
||||
|
||||
record:
|
||||
enabled: True
|
||||
retain:
|
||||
motion:
|
||||
days: 7
|
||||
mode: motion
|
||||
alerts:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
detections:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
|
||||
snapshots:
|
||||
enabled: True
|
||||
@@ -137,7 +138,10 @@ cameras:
|
||||
- detect
|
||||
motion:
|
||||
mask:
|
||||
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
|
||||
timestamp:
|
||||
friendly_name: "Camera timestamp"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
|
||||
```
|
||||
|
||||
### Standalone Intel Mini PC with USB Coral
|
||||
@@ -165,15 +169,16 @@ detectors:
|
||||
|
||||
record:
|
||||
enabled: True
|
||||
retain:
|
||||
motion:
|
||||
days: 7
|
||||
mode: motion
|
||||
alerts:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
detections:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
|
||||
snapshots:
|
||||
enabled: True
|
||||
@@ -193,7 +198,10 @@ cameras:
|
||||
- detect
|
||||
motion:
|
||||
mask:
|
||||
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
|
||||
timestamp:
|
||||
friendly_name: "Camera timestamp"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
|
||||
```
|
||||
|
||||
### Home Assistant integrated Intel Mini PC with OpenVino
|
||||
@@ -231,15 +239,16 @@ model:
|
||||
|
||||
record:
|
||||
enabled: True
|
||||
retain:
|
||||
motion:
|
||||
days: 7
|
||||
mode: motion
|
||||
alerts:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
detections:
|
||||
retain:
|
||||
days: 30
|
||||
mode: motion
|
||||
|
||||
snapshots:
|
||||
enabled: True
|
||||
@@ -259,5 +268,8 @@ cameras:
|
||||
- detect
|
||||
motion:
|
||||
mask:
|
||||
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
|
||||
timestamp:
|
||||
friendly_name: "Camera timestamp"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
|
||||
```
|
||||
|
||||
@@ -68,8 +68,8 @@ Fine-tune the LPR feature using these optional parameters at the global level of
|
||||
- Default: `1000` pixels. Note: this is intentionally set very low as it is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image.
|
||||
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
|
||||
- **`device`**: Device to use to run license plate detection _and_ recognition models.
|
||||
- Default: `CPU`
|
||||
- This can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU.
|
||||
- Default: `None`
|
||||
- This is auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation. However, for users who run a model that detects `license_plate` natively, there is little to no performance gain reported with running LPR on GPU compared to the CPU.
|
||||
- **`model_size`**: The size of the model used to identify regions of text on plates.
|
||||
- Default: `small`
|
||||
- This can be `small` or `large`.
|
||||
@@ -381,6 +381,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
|
||||
```yaml
|
||||
lpr:
|
||||
enabled: true
|
||||
device: CPU
|
||||
debug_save_plates: true
|
||||
```
|
||||
|
||||
@@ -432,6 +433,6 @@ If you are using a model that natively detects `license_plate`, add an _object m
|
||||
|
||||
If you are not using a model that natively detects `license_plate` or you are using dedicated LPR camera mode, only a _motion mask_ over your text is required.
|
||||
|
||||
### I see "Error running ... model" in my logs. How can I fix this?
|
||||
### I see "Error running ... model" in my logs, or my inference time is very high. How can I fix this?
|
||||
|
||||
This usually happens when your GPU is unable to compile or use one of the LPR models. Set your `device` to `CPU` and try again. GPU acceleration only provides a slight performance increase, and the models are lightweight enough to run without issue on most CPUs.
|
||||
|
||||
@@ -33,18 +33,55 @@ Your config file will be updated with the relative coordinates of the mask/zone:
|
||||
|
||||
```yaml
|
||||
motion:
|
||||
mask: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
|
||||
mask:
|
||||
# Motion mask name (required)
|
||||
mask1:
|
||||
# Optional: A friendly name for the mask
|
||||
friendly_name: "Timestamp area"
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
|
||||
```
|
||||
|
||||
Multiple masks can be listed in your config.
|
||||
Multiple motion masks can be listed in your config:
|
||||
|
||||
```yaml
|
||||
motion:
|
||||
mask:
|
||||
- 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802
|
||||
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456
|
||||
mask1:
|
||||
friendly_name: "Timestamp area"
|
||||
enabled: true
|
||||
coordinates: "0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802"
|
||||
mask2:
|
||||
friendly_name: "Tree area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456"
|
||||
```
|
||||
|
||||
Object filter masks can also be created through the UI or manually in the config. They are configured under the object filters section for each object type:
|
||||
|
||||
```yaml
|
||||
objects:
|
||||
filters:
|
||||
person:
|
||||
mask:
|
||||
person_filter1:
|
||||
friendly_name: "Roof area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
|
||||
car:
|
||||
mask:
|
||||
car_filter1:
|
||||
friendly_name: "Sidewalk area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
|
||||
```
|
||||
|
||||
## Enabling/Disabling Masks
|
||||
|
||||
Both motion masks and object filter masks can be toggled on or off without removing them from the configuration. Disabled masks are completely ignored at runtime - they will not affect motion detection or object filtering. This is useful for temporarily disabling a mask during certain seasons or times of day without modifying the configuration.
|
||||
|
||||
### Further Clarification
|
||||
|
||||
This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3):
|
||||
|
||||
@@ -34,7 +34,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**Nvidia GPU**
|
||||
|
||||
- [ONNX](#onnx): TensorRT will automatically be detected and used as a detector in the `-tensorrt` Frigate image when a supported ONNX model is configured.
|
||||
- [ONNX](#onnx): Nvidia GPUs will automatically be detected and used as a detector in the `-tensorrt` Frigate image when a supported ONNX model is configured.
|
||||
|
||||
**Nvidia Jetson** <CommunityBadge />
|
||||
|
||||
@@ -65,7 +65,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
|
||||
|
||||
# Officially Supported Detectors
|
||||
|
||||
Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `memryx`, `onnx`, `openvino`, `rknn`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
|
||||
## Edge TPU Detector
|
||||
|
||||
@@ -157,7 +157,13 @@ A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite`
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are supported, but not included by default. [Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes.
|
||||
YOLOv9 models that are compiled for TensorFlow Lite and properly quantized are supported, but not included by default. [Instructions](#yolov9-for-google-coral-support) for downloading a model with support for the Google Coral.
|
||||
|
||||
:::tip
|
||||
|
||||
**Frigate+ Users:** Follow the [instructions](../integrations/plus#use-models) to set a model ID in your config file.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary>YOLOv9 Setup & Config</summary>
|
||||
@@ -654,11 +660,9 @@ ONNX is an open format for building machine learning models, Frigate supports ru
|
||||
If the correct build is used for your GPU then the GPU will be detected and used automatically.
|
||||
|
||||
- **AMD**
|
||||
|
||||
- ROCm will automatically be detected and used with the ONNX detector in the `-rocm` Frigate image.
|
||||
|
||||
- **Intel**
|
||||
|
||||
- OpenVINO will automatically be detected and used with the ONNX detector in the default Frigate image.
|
||||
|
||||
- **Nvidia**
|
||||
@@ -1514,11 +1518,11 @@ RF-DETR can be exported as ONNX by running the command below. You can copy and p
|
||||
|
||||
```sh
|
||||
docker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF'
|
||||
FROM python:3.11 AS build
|
||||
FROM python:3.12 AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/
|
||||
WORKDIR /rfdetr
|
||||
RUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 onnxscript
|
||||
RUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript
|
||||
ARG MODEL_SIZE
|
||||
RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)"
|
||||
FROM scratch
|
||||
@@ -1556,7 +1560,11 @@ cd tensorrt_demos/yolo
|
||||
python3 yolo_to_onnx.py -m yolov7-320
|
||||
```
|
||||
|
||||
#### YOLOv9
|
||||
#### YOLOv9 for Google Coral Support
|
||||
|
||||
[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes.
|
||||
|
||||
#### YOLOv9 for other detectors
|
||||
|
||||
YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`).
|
||||
|
||||
|
||||
@@ -139,7 +139,13 @@ record:
|
||||
|
||||
:::tip
|
||||
|
||||
When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large.
|
||||
When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set `cameras.<camera>.record.export.hwaccel_args` with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
The encoder determines its own behavior so the resulting file size may be undesirably large.
|
||||
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
|
||||
|
||||
:::
|
||||
@@ -148,19 +154,16 @@ To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (whe
|
||||
|
||||
Apple devices running the Safari browser may fail to playback h.265 recordings. The [apple compatibility option](../configuration/camera_specific.md#h265-cameras-via-safari) should be used to ensure seamless playback on Apple devices.
|
||||
|
||||
## Syncing Recordings With Disk
|
||||
## Syncing Media Files With Disk
|
||||
|
||||
In some cases the recordings files may be deleted but Frigate will not know this has happened. Recordings sync can be enabled which will tell Frigate to check the file system and delete any db entries for files which don't exist.
|
||||
Media files (event snapshots, event thumbnails, review thumbnails, previews, exports, and recordings) can become orphaned when database entries are deleted but the corresponding files remain on disk.
|
||||
|
||||
```yaml
|
||||
record:
|
||||
sync_recordings: True
|
||||
```
|
||||
Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database.
|
||||
|
||||
This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart.
|
||||
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint.
|
||||
|
||||
:::warning
|
||||
|
||||
The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary.
|
||||
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
|
||||
|
||||
:::
|
||||
|
||||
@@ -73,11 +73,19 @@ tls:
|
||||
# Optional: Enable TLS for port 8971 (default: shown below)
|
||||
enabled: True
|
||||
|
||||
# Optional: IPv6 configuration
|
||||
# Optional: Networking configuration
|
||||
networking:
|
||||
# Optional: Enable IPv6 on 5000, and 8971 if tls is configured (default: shown below)
|
||||
ipv6:
|
||||
enabled: False
|
||||
# Optional: Override ports Frigate uses for listening (defaults: shown below)
|
||||
# An IP address may also be provided to bind to a specific interface, e.g. ip:port
|
||||
# NOTE: This setting is for advanced users and may break some integrations. The majority
|
||||
# of users should change ports in the docker compose file
|
||||
# or use the docker run `--publish` option to select a different port.
|
||||
listen:
|
||||
internal: 5000
|
||||
external: 8971
|
||||
|
||||
# Optional: Proxy configuration
|
||||
proxy:
|
||||
@@ -337,7 +345,15 @@ objects:
|
||||
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask)
|
||||
# Checks based on the bottom center of the bounding box of the object.
|
||||
# NOTE: This mask is COMBINED with the object type specific mask below
|
||||
mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
|
||||
mask:
|
||||
# Object filter mask name (required)
|
||||
mask1:
|
||||
# Optional: A friendly name for the mask
|
||||
friendly_name: "Object filter mask area"
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
|
||||
# Optional: filters to reduce false positives for specific object types
|
||||
filters:
|
||||
person:
|
||||
@@ -357,7 +373,15 @@ objects:
|
||||
threshold: 0.7
|
||||
# Optional: mask to prevent this object type from being detected in certain areas (default: no mask)
|
||||
# Checks based on the bottom center of the bounding box of the object
|
||||
mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
|
||||
mask:
|
||||
# Object filter mask name (required)
|
||||
mask1:
|
||||
# Optional: A friendly name for the mask
|
||||
friendly_name: "Object filter mask area"
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
|
||||
# Optional: Configuration for AI generated tracked object descriptions
|
||||
genai:
|
||||
# Optional: Enable AI object description generation (default: shown below)
|
||||
@@ -481,7 +505,15 @@ motion:
|
||||
frame_height: 100
|
||||
# Optional: motion mask
|
||||
# NOTE: see docs for more detailed info on creating masks
|
||||
mask: 0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000
|
||||
mask:
|
||||
# Motion mask name (required)
|
||||
mask1:
|
||||
# Optional: A friendly name for the mask
|
||||
friendly_name: "Motion mask area"
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
|
||||
# Optional: improve contrast (default: shown below)
|
||||
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
|
||||
# for daytime.
|
||||
@@ -510,8 +542,6 @@ record:
|
||||
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
||||
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
||||
expire_interval: 60
|
||||
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
|
||||
sync_recordings: False
|
||||
# Optional: Continuous retention settings
|
||||
continuous:
|
||||
# Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below)
|
||||
@@ -534,6 +564,8 @@ record:
|
||||
# The -r (framerate) dictates how smooth the output video is.
|
||||
# So the args would be -vf setpts=0.02*PTS -r 30 in that case.
|
||||
timelapse_args: "-vf setpts=0.04*PTS -r 30"
|
||||
# Optional: Global hardware acceleration settings for timelapse exports. (default: inherit)
|
||||
hwaccel_args: auto
|
||||
# Optional: Recording Preview Settings
|
||||
preview:
|
||||
# Optional: Quality of recording preview (default: shown below).
|
||||
@@ -696,6 +728,9 @@ genai:
|
||||
# Optional additional args to pass to the GenAI Provider (default: None)
|
||||
provider_options:
|
||||
keep_alive: -1
|
||||
# Optional: Options to pass during inference calls (default: {})
|
||||
runtime_options:
|
||||
temperature: 0.7
|
||||
|
||||
# Optional: Configuration for audio transcription
|
||||
# NOTE: only the enabled option can be overridden at the camera level
|
||||
@@ -749,7 +784,7 @@ classification:
|
||||
interval: None
|
||||
|
||||
# Optional: Restream configuration
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.9.10)
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.9.13)
|
||||
# NOTE: The default go2rtc API port (1984) must be used,
|
||||
# changing this port for the integrated go2rtc instance is not supported.
|
||||
go2rtc:
|
||||
@@ -835,6 +870,11 @@ cameras:
|
||||
# Optional: camera specific output args (default: inherit)
|
||||
# output_args:
|
||||
|
||||
# Optional: camera specific hwaccel args for timelapse export (default: inherit)
|
||||
# record:
|
||||
# export:
|
||||
# hwaccel_args:
|
||||
|
||||
# Optional: timeout for highest scoring image before allowing it
|
||||
# to be replaced by a newer image. (default: shown below)
|
||||
best_image_timeout: 60
|
||||
@@ -850,6 +890,9 @@ cameras:
|
||||
front_steps:
|
||||
# Optional: A friendly name or descriptive text for the zones
|
||||
friendly_name: ""
|
||||
# Optional: Whether this zone is active (default: shown below)
|
||||
# Disabled zones are completely ignored at runtime - no object tracking or debug drawing
|
||||
enabled: True
|
||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
||||
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
||||
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
|
||||
|
||||
@@ -7,7 +7,7 @@ title: Restream
|
||||
|
||||
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
|
||||
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.10) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration) for more advanced configurations and features.
|
||||
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.13) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#configuration) for more advanced configurations and features.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -206,7 +206,13 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
|
||||
:::warning
|
||||
|
||||
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
|
||||
@@ -9,4 +9,25 @@ Snapshots are accessible in the UI in the Explore pane. This allows for quick su
|
||||
|
||||
To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones)
|
||||
|
||||
Snapshots sent via MQTT are configured in the [config file](https://docs.frigate.video/configuration/) under `cameras -> your_camera -> mqtt`
|
||||
Snapshots sent via MQTT are configured in the [config file](/configuration) under `cameras -> your_camera -> mqtt`
|
||||
|
||||
## Frame Selection
|
||||
|
||||
Frigate does not save every frame — it picks a single "best" frame for each tracked object and uses it for both the snapshot and clean copy. As the object is tracked across frames, Frigate continuously evaluates whether the current frame is better than the previous best based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. The snapshot is written to disk once tracking ends using whichever frame was determined to be the best.
|
||||
|
||||
MQTT snapshots are published more frequently — each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under `cameras -> your_camera -> mqtt`.
|
||||
|
||||
## Clean Copy
|
||||
|
||||
Frigate can produce up to two snapshot files per event, each used in different places:
|
||||
|
||||
| Version | File | Annotations | Used by |
|
||||
| --- | --- | --- | --- |
|
||||
| **Regular snapshot** | `<camera>-<id>.jpg` | Respects your `timestamp`, `bounding_box`, `crop`, and `height` settings | API (`/api/events/<id>/snapshot.jpg`), MQTT (`<camera>/<label>/snapshot`), Explore pane in the UI |
|
||||
| **Clean copy** | `<camera>-<id>-clean.webp` | Always unannotated — no bounding box, no timestamp, no crop, full resolution | API (`/api/events/<id>/snapshot-clean.webp`), [Frigate+](/plus/first_model) submissions, "Download Clean Snapshot" in the UI |
|
||||
|
||||
MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the clean copy.
|
||||
|
||||
The clean copy is required for submitting events to [Frigate+](/plus/first_model) — if you plan to use Frigate+, keep `clean_copy` enabled regardless of your other snapshot settings.
|
||||
|
||||
If you are not using Frigate+ and `timestamp`, `bounding_box`, and `crop` are all disabled, the regular snapshot is already effectively clean, so `clean_copy` provides no benefit and only uses additional disk space. You can safely set `clean_copy: False` in this case.
|
||||
|
||||
@@ -10,6 +10,10 @@ For example, the cat in this image is currently in Zone 1, but **not** Zone 2.
|
||||
|
||||
Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
|
||||
|
||||
## Enabling/Disabling Zones
|
||||
|
||||
Zones can be toggled on or off without removing them from the configuration. Disabled zones are completely ignored at runtime - objects will not be tracked for zone presence, and zones will not appear in the debug view. This is useful for temporarily disabling a zone during certain seasons or times of day without modifying the configuration.
|
||||
|
||||
During testing, enable the Zones option for the Debug view of your camera (Settings --> Debug) so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.
|
||||
|
||||
To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead.
|
||||
@@ -86,7 +90,6 @@ cameras:
|
||||
|
||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
|
||||
|
||||
|
||||
### Zone Loitering
|
||||
|
||||
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
|
||||
@@ -94,6 +97,7 @@ Sometimes objects are expected to be passing through a zone, but an object loite
|
||||
:::note
|
||||
|
||||
When using loitering zones, a review item will behave in the following way:
|
||||
|
||||
- When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary.
|
||||
- When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end.
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ Cameras configured to output H.264 video and AAC audio will offer the most compa
|
||||
|
||||
- **Stream Viewing**: This stream will be rebroadcast as is to Home Assistant for viewing with the stream component. Setting this resolution too high will use significant bandwidth when viewing streams in Home Assistant, and they may not load reliably over slower connections.
|
||||
|
||||
:::tip
|
||||
|
||||
For the best experience in Frigate's UI, configure your camera so that the detection and recording streams use the same aspect ratio. For example, if your main stream is 3840x2160 (16:9), set your substream to 640x360 (also 16:9) instead of 640x480 (4:3). While not strictly required, matching aspect ratios helps ensure seamless live stream display and preview/recordings playback.
|
||||
|
||||
:::
|
||||
|
||||
### Choosing a detect resolution
|
||||
|
||||
The ideal resolution for detection is one where the objects you want to detect fit inside the dimensions of the model used by Frigate (320x320). Frigate does not pass the entire camera frame to object detection. It will crop an area of motion from the full frame and look in that portion of the frame. If the area being inspected is larger than 320x320, Frigate must resize it before running object detection. Higher resolutions do not improve the detection accuracy because the additional detail is lost in the resize. Below you can see a reference for how large a 320x320 area is against common resolutions.
|
||||
|
||||
@@ -41,8 +41,8 @@ If the EQ13 is out of stock, the link below may take you to a suggested alternat
|
||||
| Name | Capabilities | Notes |
|
||||
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | Can run object detection on several 1080p cameras with low-medium activity | Dual gigabit NICs for easy isolated camera network. |
|
||||
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP) | Can handle a large number of 1080p cameras with high activity | |
|
||||
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
|
||||
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP)) | Can handle a large number of 1080p cameras with high activity | |
|
||||
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM)) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
|
||||
|
||||
## Detectors
|
||||
|
||||
@@ -55,12 +55,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
**Most Hardware**
|
||||
|
||||
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
|
||||
|
||||
- [Supports many model architectures](../../configuration/object_detectors#configuration)
|
||||
- Runs best with tiny or small size models
|
||||
|
||||
- [Google Coral EdgeTPU](#google-coral-tpu): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices.
|
||||
|
||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#edge-tpu-detector)
|
||||
|
||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 M.2 accelerator module is available in m.2 format allowing for a wide range of compatibility with devices.
|
||||
@@ -88,8 +86,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**Nvidia**
|
||||
|
||||
- [TensortRT](#tensorrt---nvidia-gpu): TensorRT can run on Nvidia GPUs to provide efficient object detection.
|
||||
|
||||
- [Nvidia GPU](#nvidia-gpus): Nvidia GPUs can provide efficient object detection.
|
||||
- [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx-supported-models)
|
||||
- Runs well with any size models including large
|
||||
|
||||
@@ -152,9 +149,7 @@ The OpenVINO detector type is able to run on:
|
||||
|
||||
:::note
|
||||
|
||||
Intel NPUs have seen [limited success in community deployments](https://github.com/blakeblackshear/frigate/discussions/13248#discussioncomment-12347357), although they remain officially unsupported.
|
||||
|
||||
In testing, the NPU delivered performance that was only comparable to — or in some cases worse than — the integrated GPU.
|
||||
Intel B-series (Battlemage) GPUs are not officially supported with Frigate 0.17, though a user has [provided steps to rebuild the Frigate container](https://github.com/blakeblackshear/frigate/discussions/21257) with support for them.
|
||||
|
||||
:::
|
||||
|
||||
@@ -172,12 +167,12 @@ Inference speeds vary greatly depending on the CPU or GPU used, some known examp
|
||||
| Intel N100 | ~ 15 ms | s-320: 30 ms | 320: ~ 25 ms | | Can only run one detector instance |
|
||||
| Intel N150 | ~ 15 ms | t-320: 16 ms s-320: 24 ms | | | |
|
||||
| Intel Iris XE | ~ 10 ms | t-320: 6 ms t-640: 14 ms s-320: 8 ms s-640: 16 ms | 320: ~ 10 ms 640: ~ 20 ms | 320-n: 33 ms | |
|
||||
| Intel NPU | ~ 6 ms | s-320: 11 ms | 320: ~ 14 ms 640: ~ 34 ms | 320-n: 40 ms | |
|
||||
| Intel NPU | ~ 6 ms | s-320: 11 ms s-640: 30 ms | 320: ~ 14 ms 640: ~ 34 ms | 320-n: 40 ms | |
|
||||
| Intel Arc A310 | ~ 5 ms | t-320: 7 ms t-640: 11 ms s-320: 8 ms s-640: 15 ms | 320: ~ 8 ms 640: ~ 14 ms | | |
|
||||
| Intel Arc A380 | ~ 6 ms | | 320: ~ 10 ms 640: ~ 22 ms | 336: 20 ms 448: 27 ms | |
|
||||
| Intel Arc A750 | ~ 4 ms | | 320: ~ 8 ms | | |
|
||||
|
||||
### TensorRT - Nvidia GPU
|
||||
### Nvidia GPUs
|
||||
|
||||
Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA libraries.
|
||||
|
||||
@@ -187,17 +182,15 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
|
||||
|
||||
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
|
||||
|
||||
There are improved capabilities in newer GPU architectures that TensorRT can benefit from, such as INT8 operations and Tensor cores. The features compatible with your hardware will be optimized when the model is converted to a trt file. Currently the script provided for generating the model provides a switch to enable/disable FP16 operations. If you wish to use newer features such as INT8 optimization, more work is required.
|
||||
|
||||
#### Compatibility References:
|
||||
|
||||
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html)
|
||||
[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html)
|
||||
|
||||
[NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html)
|
||||
|
||||
[NVIDIA GPU Compute Capability](https://developer.nvidia.com/cuda-gpus)
|
||||
|
||||
Inference speeds will vary greatly depending on the GPU and the model used.
|
||||
Inference is done with the `onnx` detector type. Speeds will vary greatly depending on the GPU and the model used.
|
||||
`tiny (t)` variants are faster than the equivalent non-tiny model, some known examples are below:
|
||||
|
||||
✅ - Accelerated with CUDA Graphs
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
volumes:
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
- type: tmpfs # Recommended: 1GB of memory
|
||||
- type: tmpfs # 1GB In-memory filesystem for recording segment storage
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
@@ -112,19 +112,23 @@ The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form
|
||||
|
||||
:::warning
|
||||
|
||||
The Raspberry Pi kernel includes an older version of the Hailo driver that is incompatible with Frigate. You **must** follow the installation steps below to install the correct driver version, and you **must** disable the built-in kernel driver as described in step 1.
|
||||
On Raspberry Pi OS **Bookworm**, the kernel includes an older version of the Hailo driver that is incompatible with Frigate. You **must** follow the installation steps below to install the correct driver version, and you **must** disable the built-in kernel driver as described in step 1.
|
||||
|
||||
On Raspberry Pi OS **Trixie**, the Hailo driver is no longer shipped with the kernel. It is installed via DKMS, and the conflict described below does not apply. You can simply run the installation script.
|
||||
|
||||
:::
|
||||
|
||||
1. **Disable the built-in Hailo driver (Raspberry Pi only)**:
|
||||
1. **Disable the built-in Hailo driver (Raspberry Pi Bookworm OS only)**:
|
||||
|
||||
:::note
|
||||
|
||||
If you are **not** using a Raspberry Pi, skip this step and proceed directly to step 2.
|
||||
If you are **not** using a Raspberry Pi with **Bookworm OS**, skip this step and proceed directly to step 2.
|
||||
|
||||
If you are using Raspberry Pi with **Trixie OS**, also skip this step and proceed directly to step 2.
|
||||
|
||||
:::
|
||||
|
||||
If you are using a Raspberry Pi, you need to blacklist the built-in kernel Hailo driver to prevent conflicts. First, check if the driver is currently loaded:
|
||||
First, check if the driver is currently loaded:
|
||||
|
||||
```bash
|
||||
lsmod | grep hailo
|
||||
@@ -133,19 +137,39 @@ The Raspberry Pi kernel includes an older version of the Hailo driver that is in
|
||||
If it shows `hailo_pci`, unload it:
|
||||
|
||||
```bash
|
||||
sudo rmmod hailo_pci
|
||||
sudo modprobe -r hailo_pci
|
||||
```
|
||||
|
||||
Now blacklist the driver to prevent it from loading on boot:
|
||||
Then locate the built-in kernel driver and rename it so it cannot be loaded.
|
||||
Renaming allows the original driver to be restored later if needed.
|
||||
First, locate the currently installed kernel module:
|
||||
|
||||
```bash
|
||||
echo "blacklist hailo_pci" | sudo tee /etc/modprobe.d/blacklist-hailo_pci.conf
|
||||
modinfo -n hailo_pci
|
||||
```
|
||||
|
||||
Update initramfs to ensure the blacklist takes effect:
|
||||
Example output:
|
||||
|
||||
```
|
||||
/lib/modules/6.6.31+rpt-rpi-2712/kernel/drivers/media/pci/hailo/hailo_pci.ko.xz
|
||||
```
|
||||
|
||||
Save the module path to a variable:
|
||||
|
||||
```bash
|
||||
sudo update-initramfs -u
|
||||
BUILTIN=$(modinfo -n hailo_pci)
|
||||
```
|
||||
|
||||
And rename the module by appending .bak:
|
||||
|
||||
```bash
|
||||
sudo mv "$BUILTIN" "${BUILTIN}.bak"
|
||||
```
|
||||
|
||||
Now refresh the kernel module map so the system recognizes the change:
|
||||
|
||||
```bash
|
||||
sudo depmod -a
|
||||
```
|
||||
|
||||
Reboot your Raspberry Pi:
|
||||
@@ -160,7 +184,7 @@ The Raspberry Pi kernel includes an older version of the Hailo driver that is in
|
||||
lsmod | grep hailo
|
||||
```
|
||||
|
||||
This command should return no results. If it still shows `hailo_pci`, the blacklist did not take effect properly and you may need to check for other Hailo packages installed via apt that are loading the driver.
|
||||
This command should return no results.
|
||||
|
||||
2. **Run the installation script**:
|
||||
|
||||
@@ -183,7 +207,6 @@ The Raspberry Pi kernel includes an older version of the Hailo driver that is in
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
- Install necessary build dependencies
|
||||
- Clone and build the Hailo driver from the official repository
|
||||
- Install the driver
|
||||
@@ -212,6 +235,38 @@ The Raspberry Pi kernel includes an older version of the Hailo driver that is in
|
||||
lsmod | grep hailo_pci
|
||||
```
|
||||
|
||||
Verify the driver version:
|
||||
|
||||
```bash
|
||||
cat /sys/module/hailo_pci/version
|
||||
```
|
||||
|
||||
Verify that the firmware was installed correctly:
|
||||
|
||||
```bash
|
||||
ls -l /lib/firmware/hailo/hailo8_fw.bin
|
||||
```
|
||||
|
||||
**Optional: Fix PCIe descriptor page size error**
|
||||
|
||||
If you encounter the following error:
|
||||
|
||||
```
|
||||
[HailoRT] [error] CHECK failed - max_desc_page_size given 16384 is bigger than hw max desc page size 4096
|
||||
```
|
||||
|
||||
Create a configuration file to force the correct descriptor page size:
|
||||
|
||||
```bash
|
||||
echo 'options hailo_pci force_desc_page_size=4096' | sudo tee /etc/modprobe.d/hailo_pci.conf
|
||||
```
|
||||
|
||||
and reboot:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
@@ -407,7 +462,7 @@ services:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
- type: tmpfs # Recommended: 1GB of memory
|
||||
- type: tmpfs # 1GB In-memory filesystem for recording segment storage
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
@@ -447,12 +502,12 @@ The official docker image tags for the current stable version are:
|
||||
|
||||
- `stable` - Standard Frigate build for amd64 & RPi Optimized Frigate build for arm64. This build includes support for Hailo devices as well.
|
||||
- `stable-standard-arm64` - Standard Frigate build for arm64
|
||||
- `stable-tensorrt` - Frigate build specific for amd64 devices running an nvidia GPU
|
||||
- `stable-tensorrt` - Frigate build specific for amd64 devices running an Nvidia GPU
|
||||
- `stable-rocm` - Frigate build for [AMD GPUs](../configuration/object_detectors.md#amdrocm-gpu-detector)
|
||||
|
||||
The community supported docker image tags for the current stable version are:
|
||||
|
||||
- `stable-tensorrt-jp6` - Frigate build optimized for nvidia Jetson devices running Jetpack 6
|
||||
- `stable-tensorrt-jp6` - Frigate build optimized for Nvidia Jetson devices running Jetpack 6
|
||||
- `stable-rk` - Frigate build for SBCs with Rockchip SoC
|
||||
|
||||
## Home Assistant Add-on
|
||||
@@ -466,7 +521,7 @@ There are important limitations in HA OS to be aware of:
|
||||
- Separate local storage for media is not yet supported by Home Assistant
|
||||
- AMD GPUs are not supported because HA OS does not include the mesa driver.
|
||||
- Intel NPUs are not supported because HA OS does not include the NPU firmware.
|
||||
- Nvidia GPUs are not supported because addons do not support the nvidia runtime.
|
||||
- Nvidia GPUs are not supported because addons do not support the Nvidia runtime.
|
||||
|
||||
:::
|
||||
|
||||
@@ -634,3 +689,43 @@ docker run \
|
||||
```
|
||||
|
||||
Log into QNAP, open Container Station. Frigate docker container should be listed under 'Overview' and running. Visit Frigate Web UI by clicking Frigate docker, and then clicking the URL shown at the top of the detail page.
|
||||
|
||||
## macOS - Apple Silicon
|
||||
|
||||
:::warning
|
||||
|
||||
macOS uses port 5000 for its Airplay Receiver service. If you want to expose port 5000 in Frigate for local app and API access the port will need to be mapped to another port on the host e.g. 5001
|
||||
|
||||
Failure to remap port 5000 on the host will result in the WebUI and all API endpoints on port 5000 being unreachable, even if port 5000 is exposed correctly in Docker.
|
||||
|
||||
:::
|
||||
|
||||
Docker containers on macOS can be orchestrated by either [Docker Desktop](https://docs.docker.com/desktop/setup/install/mac-install/) or [OrbStack](https://orbstack.dev) (native swift app). The difference in inference speeds is negligable, however CPU, power consumption and container start times will be lower on OrbStack because it is a native Swift application.
|
||||
|
||||
To allow Frigate to use the Apple Silicon Neural Engine / Processing Unit (NPU) the host must be running [Apple Silicon Detector](../configuration/object_detectors.md#apple-silicon-detector) on the host (outside Docker)
|
||||
|
||||
#### Docker Compose example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
container_name: frigate
|
||||
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
|
||||
restart: unless-stopped
|
||||
shm_size: "512mb" # update for your cameras based on calculation above
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/recordings:/recordings
|
||||
ports:
|
||||
- "8971:8971"
|
||||
# If exposing on macOS map to a diffent host port like 5001 or any orher port with no conflicts
|
||||
# - "5001:5000" # Internal unauthenticated access. Expose carefully.
|
||||
- "8554:8554" # RTSP feeds
|
||||
extra_hosts:
|
||||
# This is very important
|
||||
# It allows frigate access to the NPU on Apple Silicon via Apple Silicon Detector
|
||||
- "host.docker.internal:host-gateway" # Required to talk to the NPU detector
|
||||
environment:
|
||||
- FRIGATE_RTSP_PASSWORD: "password"
|
||||
```
|
||||
|
||||
@@ -20,7 +20,6 @@ Keeping Frigate up to date ensures you benefit from the latest features, perform
|
||||
If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
|
||||
1. **Stop the Container**:
|
||||
|
||||
- If using Docker Compose:
|
||||
```bash
|
||||
docker compose down frigate
|
||||
@@ -31,9 +30,8 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
```
|
||||
|
||||
2. **Update and Pull the Latest Image**:
|
||||
|
||||
- If using Docker Compose:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.3`). For example:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example:
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
@@ -51,7 +49,6 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
```
|
||||
|
||||
3. **Start the Container**:
|
||||
|
||||
- If using Docker Compose:
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -75,18 +72,15 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
For users running Frigate as a Home Assistant Addon:
|
||||
|
||||
1. **Check for Updates**:
|
||||
|
||||
- Navigate to **Settings > Add-ons** in Home Assistant.
|
||||
- Find your installed Frigate addon (e.g., "Frigate NVR" or "Frigate NVR (Full Access)").
|
||||
- If an update is available, you’ll see an "Update" button.
|
||||
|
||||
2. **Update the Addon**:
|
||||
|
||||
- Click the "Update" button next to the Frigate addon.
|
||||
- Wait for the process to complete. Home Assistant will handle downloading and installing the new version.
|
||||
|
||||
3. **Restart the Addon**:
|
||||
|
||||
- After updating, go to the addon’s page and click "Restart" to apply the changes.
|
||||
|
||||
4. **Verify the Update**:
|
||||
@@ -105,8 +99,8 @@ If an update causes issues:
|
||||
1. Stop Frigate.
|
||||
2. Restore your backed-up config file and database.
|
||||
3. Revert to the previous image version:
|
||||
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.3`) in your `docker run` command.
|
||||
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.3`), and re-run `docker compose up -d`.
|
||||
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`) in your `docker run` command.
|
||||
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`), and re-run `docker compose up -d`.
|
||||
- For Home Assistant: Reinstall the previous addon version manually via the repository if needed and restart the addon.
|
||||
4. Verify the old version is running again.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
|
||||
|
||||
## Setup a go2rtc stream
|
||||
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#module-streams), not just rtsp.
|
||||
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#module-streams), not just rtsp.
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -47,8 +47,8 @@ After adding this to the config, restart Frigate and try to watch the live strea
|
||||
- Check Video Codec:
|
||||
|
||||
- If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported.
|
||||
- If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#codecs-madness) in go2rtc documentation.
|
||||
- If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
|
||||
- If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#codecs-madness) in go2rtc documentation.
|
||||
- If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- ./storage:/media/frigate
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
- type: tmpfs # 1GB In-memory filesystem for recording segment storage
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
@@ -240,7 +240,10 @@ cameras:
|
||||
- detect
|
||||
motion:
|
||||
mask:
|
||||
- 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
|
||||
motion_area:
|
||||
friendly_name: "Motion mask"
|
||||
enabled: true
|
||||
coordinates: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432"
|
||||
```
|
||||
|
||||
### Step 6: Enable recordings
|
||||
|
||||
@@ -16,7 +16,15 @@ See the [MQTT integration
|
||||
documentation](https://www.home-assistant.io/integrations/mqtt/) for more
|
||||
details.
|
||||
|
||||
In addition, MQTT must be enabled in your Frigate configuration file and Frigate must be connected to the same MQTT server as Home Assistant for many of the entities created by the integration to function.
|
||||
In addition, MQTT must be enabled in your Frigate configuration file and Frigate must be connected to the same MQTT server as Home Assistant for many of the entities created by the integration to function, e.g.:
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
enabled: True
|
||||
host: mqtt.server.com # the address of your HA server that's running the MQTT integration
|
||||
user: your_mqtt_broker_username
|
||||
password: your_mqtt_broker_password
|
||||
```
|
||||
|
||||
### Integration installation
|
||||
|
||||
@@ -95,12 +103,12 @@ services:
|
||||
|
||||
If you are using Home Assistant Add-on, the URL should be one of the following depending on which Add-on variant you are using. Note that if you are using the Proxy Add-on, you should NOT point the integration at the proxy URL. Just enter the same URL used to access Frigate directly from your network.
|
||||
|
||||
| Add-on Variant | URL |
|
||||
| -------------------------- | ----------------------------------------- |
|
||||
| Frigate | `http://ccab4aaf-frigate:5000` |
|
||||
| Frigate (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
|
||||
| Frigate Beta | `http://ccab4aaf-frigate-beta:5000` |
|
||||
| Frigate Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
|
||||
| Add-on Variant | URL |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| Frigate | `http://ccab4aaf-frigate:5000` |
|
||||
| Frigate (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
|
||||
| Frigate Beta | `http://ccab4aaf-frigate-beta:5000` |
|
||||
| Frigate Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
|
||||
|
||||
### Frigate running on a separate machine
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ Message published for each changed tracked object. The first message is publishe
|
||||
|
||||
### `frigate/tracked_object_update`
|
||||
|
||||
Message published for updates to tracked object metadata, for example:
|
||||
Message published for updates to tracked object metadata. All messages include an `id` field which is the tracked object's event ID, and can be used to look up the event via the API or match it to items in the UI.
|
||||
|
||||
#### Generative AI Description Update
|
||||
|
||||
@@ -134,12 +134,14 @@ Message published for updates to tracked object metadata, for example:
|
||||
|
||||
#### Face Recognition Update
|
||||
|
||||
Published after each recognition attempt, regardless of whether the score meets `recognition_threshold`. See the [Face Recognition](/configuration/face_recognition) documentation for details on how scoring works.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "face",
|
||||
"id": "1607123955.475377-mxklsc",
|
||||
"name": "John",
|
||||
"score": 0.95,
|
||||
"name": "John", // best matching person, or null if no match
|
||||
"score": 0.95, // running weighted average across all recognition attempts
|
||||
"camera": "front_door_cam",
|
||||
"timestamp": 1607123958.748393
|
||||
}
|
||||
@@ -147,11 +149,13 @@ Message published for updates to tracked object metadata, for example:
|
||||
|
||||
#### License Plate Recognition Update
|
||||
|
||||
Published when a license plate is recognized on a car object. See the [License Plate Recognition](/configuration/license_plate_recognition) documentation for details.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lpr",
|
||||
"id": "1607123955.475377-mxklsc",
|
||||
"name": "John's Car",
|
||||
"name": "John's Car", // known name for the plate, or null
|
||||
"plate": "123ABC",
|
||||
"score": 0.95,
|
||||
"camera": "driveway_cam",
|
||||
@@ -425,6 +429,30 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
|
||||
|
||||
Topic with current motion contour area for a camera. Published value is an integer.
|
||||
|
||||
### `frigate/<camera_name>/motion_mask/<mask_name>/set`
|
||||
|
||||
Topic to turn a specific motion mask for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/motion_mask/<mask_name>/state`
|
||||
|
||||
Topic with current state of a specific motion mask for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/object_mask/<mask_name>/set`
|
||||
|
||||
Topic to turn a specific object mask for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/object_mask/<mask_name>/state`
|
||||
|
||||
Topic with current state of a specific object mask for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/zone/<zone_name>/set`
|
||||
|
||||
Topic to turn a specific zone for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/zone/<zone_name>/state`
|
||||
|
||||
Topic with current state of a specific zone for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/review_status`
|
||||
|
||||
Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`.
|
||||
|
||||
@@ -54,6 +54,8 @@ Once you have [requested your first model](../plus/first_model.md) and gotten yo
|
||||
You can either choose the new model from the Frigate+ pane in the Settings page of the Frigate UI, or manually set the model at the root level in your config:
|
||||
|
||||
```yaml
|
||||
detectors: ...
|
||||
|
||||
model:
|
||||
path: plus://<your_model_id>
|
||||
```
|
||||
|
||||
@@ -24,6 +24,8 @@ You will receive an email notification when your Frigate+ model is ready.
|
||||
Models available in Frigate+ can be used with a special model path. No other information needs to be configured because it fetches the remaining config from Frigate+ automatically.
|
||||
|
||||
```yaml
|
||||
detectors: ...
|
||||
|
||||
model:
|
||||
path: plus://<your_model_id>
|
||||
```
|
||||
|
||||
@@ -15,15 +15,15 @@ There are three model types offered in Frigate+, `mobiledet`, `yolonas`, and `yo
|
||||
|
||||
Not all model types are supported by all detectors, so it's important to choose a model type to match your detector as shown in the table under [supported detector types](#supported-detector-types). You can test model types for compatibility and speed on your hardware by using the base models.
|
||||
|
||||
| Model Type | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `mobiledet` | Based on the same architecture as the default model included with Frigate. Runs on Google Coral devices and CPUs. |
|
||||
| `yolonas` | A newer architecture that offers slightly higher accuracy and improved detection of small objects. Runs on Intel, NVidia GPUs, and AMD GPUs. |
|
||||
| `yolov9` | A leading SOTA (state of the art) object detection model with similar performance to yolonas, but on a wider range of hardware options. Runs on Intel, NVidia GPUs, AMD GPUs, Hailo, MemryX, Apple Silicon, and Rockchip NPUs. |
|
||||
| Model Type | Description |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mobiledet` | Based on the same architecture as the default model included with Frigate. Runs on Google Coral devices and CPUs. |
|
||||
| `yolonas` | A newer architecture that offers slightly higher accuracy and improved detection of small objects. Runs on Intel, NVidia GPUs, and AMD GPUs. |
|
||||
| `yolov9` | A leading SOTA (state of the art) object detection model with similar performance to yolonas, but on a wider range of hardware options. Runs on most hardware. |
|
||||
|
||||
### YOLOv9 Details
|
||||
|
||||
YOLOv9 models are available in `s` and `t` sizes. When requesting a `yolov9` model, you will be prompted to choose a size. If you are unsure what size to choose, you should perform some tests with the base models to find the performance level that suits you. The `s` size is most similar to the current `yolonas` models in terms of inference times and accuracy, and a good place to start is the `320x320` resolution model for `yolov9s`.
|
||||
YOLOv9 models are available in `s`, `t`, `edgetpu` variants. When requesting a `yolov9` model, you will be prompted to choose a variant. If you want the model to be compatible with a Google Coral, you will need to choose the `edgetpu` variant. If you are unsure what variant to choose, you should perform some tests with the base models to find the performance level that suits you. The `s` size is most similar to the current `yolonas` models in terms of inference times and accuracy, and a good place to start is the `320x320` resolution model for `yolov9s`.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -37,23 +37,21 @@ If you have a Hailo device, you will need to specify the hardware you have when
|
||||
|
||||
#### Rockchip (RKNN) Support
|
||||
|
||||
For 0.16, YOLOv9 onnx models will need to be manually converted. First, you will need to configure Frigate to use the model id for your YOLOv9 onnx model so it downloads the model to your `model_cache` directory. From there, you can follow the [documentation](/configuration/object_detectors.md#converting-your-own-onnx-model-to-rknn-format) to convert it. Automatic conversion is available in 0.17 and later.
|
||||
Rockchip models are automatically converted as of 0.17. For 0.16, YOLOv9 onnx models will need to be manually converted. First, you will need to configure Frigate to use the model id for your YOLOv9 onnx model so it downloads the model to your `model_cache` directory. From there, you can follow the [documentation](/configuration/object_detectors.md#converting-your-own-onnx-model-to-rknn-format) to convert it.
|
||||
|
||||
## Supported detector types
|
||||
|
||||
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo8l`), and Rockchip\* (`rknn`) detectors.
|
||||
Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), Hailo (`hailo8l`), and Rockchip (`rknn`) detectors.
|
||||
|
||||
| Hardware | Recommended Detector Type | Recommended Model Type |
|
||||
| -------------------------------------------------------------------------------- | ------------------------- | ---------------------- |
|
||||
| [CPU](/configuration/object_detectors.md#cpu-detector-not-recommended) | `cpu` | `mobiledet` |
|
||||
| [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `mobiledet` |
|
||||
| [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `yolov9` |
|
||||
| [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolov9` |
|
||||
| [NVidia GPU](/configuration/object_detectors#onnx) | `onnx` | `yolov9` |
|
||||
| [AMD ROCm GPU](/configuration/object_detectors#amdrocm-gpu-detector) | `onnx` | `yolov9` |
|
||||
| [Hailo8/Hailo8L/Hailo8R](/configuration/object_detectors#hailo-8) | `hailo8l` | `yolov9` |
|
||||
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform)\* | `rknn` | `yolov9` |
|
||||
|
||||
_\* Requires manual conversion in 0.16. Automatic conversion available in 0.17 and later._
|
||||
| [Rockchip NPU](/configuration/object_detectors#rockchip-platform) | `rknn` | `yolov9` |
|
||||
|
||||
## Improving your model
|
||||
|
||||
@@ -81,7 +79,7 @@ Candidate labels are also available for annotation. These labels don't have enou
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ cameras:
|
||||
|
||||
## Steps
|
||||
|
||||
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`).
|
||||
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting.
|
||||
2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later.
|
||||
- If you're debugging a specific camera, copy the settings from that camera (frame rate, model/enrichment settings, zones, etc.) into the temporary camera so the replay closely matches the original environment. Leave `record` and `snapshots` disabled unless you are specifically debugging recording or snapshot behavior.
|
||||
3. Restart Frigate.
|
||||
|
||||
6
docs/package-lock.json
generated
6
docs/package-lock.json
generated
@@ -18490,9 +18490,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
|
||||
@@ -28,7 +28,7 @@ const sidebars: SidebarsConfig = {
|
||||
{
|
||||
type: "link",
|
||||
label: "Go2RTC Configuration Reference",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration",
|
||||
href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.13#configuration",
|
||||
} as PropSidebarItemLink,
|
||||
],
|
||||
Detectors: [
|
||||
|
||||
8
docs/static/_headers
vendored
Normal file
8
docs/static/_headers
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
https://:project.pages.dev/*
|
||||
X-Robots-Tag: noindex
|
||||
|
||||
https://:version.:project.pages.dev/*
|
||||
X-Robots-Tag: noindex
|
||||
|
||||
https://docs-dev.frigate.video/*
|
||||
X-Robots-Tag: noindex
|
||||
60
docs/static/frigate-api.yaml
vendored
60
docs/static/frigate-api.yaml
vendored
@@ -331,6 +331,59 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HTTPValidationError"
|
||||
/media/sync:
|
||||
post:
|
||||
tags:
|
||||
- App
|
||||
summary: Start media sync job
|
||||
description: |-
|
||||
Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
|
||||
Returns 202 with job details when queued, or 409 if a job is already running.
|
||||
operationId: sync_media_media_sync_post
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted - Job queued
|
||||
"409":
|
||||
description: Conflict - Job already running
|
||||
"422":
|
||||
description: Validation Error
|
||||
|
||||
/media/sync/current:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Get current media sync job
|
||||
description: |-
|
||||
Retrieve the current running media sync job, if any. Returns the job details or null when no job is active.
|
||||
operationId: get_media_sync_current_media_sync_current_get
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
"422":
|
||||
description: Validation Error
|
||||
|
||||
/media/sync/status/{job_id}:
|
||||
get:
|
||||
tags:
|
||||
- App
|
||||
summary: Get media sync job status
|
||||
description: |-
|
||||
Get status and results for the specified media sync job id. Returns 200 with job details including results, or 404 if the job is not found.
|
||||
operationId: get_media_sync_status_media_sync_status__job_id__get
|
||||
parameters:
|
||||
- name: job_id
|
||||
in: path
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
"404":
|
||||
description: Not Found - Job not found
|
||||
"422":
|
||||
description: Validation Error
|
||||
/faces/train/{name}/classify:
|
||||
post:
|
||||
tags:
|
||||
@@ -3147,6 +3200,7 @@ paths:
|
||||
duration: 30
|
||||
include_recording: true
|
||||
draw: {}
|
||||
pre_capture: null
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Response
|
||||
@@ -4949,6 +5003,12 @@ components:
|
||||
- type: "null"
|
||||
title: Draw
|
||||
default: {}
|
||||
pre_capture:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: "null"
|
||||
title: Pre Capture Seconds
|
||||
default: null
|
||||
type: object
|
||||
title: EventsCreateBody
|
||||
EventsDeleteBody:
|
||||
|
||||
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
BIN
docs/static/img/frigate-autotracking-example.gif
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 28 MiB After Width: | Height: | Size: 12 MiB |
@@ -19,28 +19,43 @@ from fastapi import APIRouter, Body, Path, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||
from filelock import FileLock, Timeout
|
||||
from markupsafe import escape
|
||||
from peewee import SQL, fn, operator
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.api.auth import allow_any_authenticated, allow_public, require_role
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
allow_public,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import AppConfigSetBody
|
||||
from frigate.api.defs.request.app_body import AppConfigSetBody, MediaSyncBody
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.jobs.media_sync import (
|
||||
get_current_media_sync_job,
|
||||
get_media_sync_job_by_id,
|
||||
start_media_sync_job,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
process_logs,
|
||||
@@ -65,9 +80,7 @@ def is_healthy():
|
||||
|
||||
@router.get("/config/schema.json", dependencies=[Depends(allow_public())])
|
||||
def config_schema(request: Request):
|
||||
return Response(
|
||||
content=request.app.frigate_config.schema_json(), media_type="application/json"
|
||||
)
|
||||
return JSONResponse(content=get_config_schema(FrigateConfig))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -113,6 +126,10 @@ def config(request: Request):
|
||||
config: dict[str, dict[str, Any]] = config_obj.model_dump(
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
config["detectors"] = {
|
||||
name: detector.model_dump(mode="json", warnings="none", exclude_none=True)
|
||||
for name, detector in config_obj.detectors.items()
|
||||
}
|
||||
|
||||
# remove the mqtt password
|
||||
config["mqtt"].pop("password", None)
|
||||
@@ -183,6 +200,54 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())])
|
||||
def ffmpeg_presets():
|
||||
"""Return available ffmpeg preset keys for config UI usage."""
|
||||
|
||||
# Whitelist based on documented presets in ffmpeg_presets.md
|
||||
hwaccel_presets = [
|
||||
"preset-rpi-64-h264",
|
||||
"preset-rpi-64-h265",
|
||||
"preset-vaapi",
|
||||
"preset-intel-qsv-h264",
|
||||
"preset-intel-qsv-h265",
|
||||
"preset-nvidia",
|
||||
"preset-jetson-h264",
|
||||
"preset-jetson-h265",
|
||||
"preset-rkmpp",
|
||||
]
|
||||
input_presets = [
|
||||
"preset-http-jpeg-generic",
|
||||
"preset-http-mjpeg-generic",
|
||||
"preset-http-reolink",
|
||||
"preset-rtmp-generic",
|
||||
"preset-rtsp-generic",
|
||||
"preset-rtsp-restream",
|
||||
"preset-rtsp-restream-low-latency",
|
||||
"preset-rtsp-udp",
|
||||
"preset-rtsp-blue-iris",
|
||||
]
|
||||
record_output_presets = [
|
||||
"preset-record-generic",
|
||||
"preset-record-generic-audio-copy",
|
||||
"preset-record-generic-audio-aac",
|
||||
"preset-record-mjpeg",
|
||||
"preset-record-jpeg",
|
||||
"preset-record-ubiquiti",
|
||||
]
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"hwaccel_args": hwaccel_presets,
|
||||
"input_args": input_presets,
|
||||
"output_args": {
|
||||
"record": record_output_presets,
|
||||
"detect": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_raw_paths(request: Request):
|
||||
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
|
||||
@@ -360,105 +425,136 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
|
||||
with open(config_file, "r") as f:
|
||||
old_raw_config = f.read()
|
||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||
|
||||
try:
|
||||
updates = {}
|
||||
with lock:
|
||||
with open(config_file, "r") as f:
|
||||
old_raw_config = f.read()
|
||||
|
||||
# process query string parameters (takes precedence over body.config_data)
|
||||
parsed_url = urllib.parse.urlparse(str(request.url))
|
||||
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
|
||||
try:
|
||||
updates = {}
|
||||
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
# process query string parameters (takes precedence over body.config_data)
|
||||
parsed_url = urllib.parse.urlparse(str(request.url))
|
||||
query_string = urllib.parse.parse_qs(
|
||||
parsed_url.query, keep_blank_values=True
|
||||
)
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
elif body.config_data:
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No configuration data provided"}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
elif body.config_data:
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "No configuration data provided",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# validate the updated config
|
||||
with open(config_file, "r") as f:
|
||||
new_raw_config = f.read()
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
# validate the updated config
|
||||
with open(config_file, "r") as f:
|
||||
new_raw_config = f.read()
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Error parsing config. Check logs for error message.",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating config: {e}")
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error updating config"}),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
request.app.genai_manager.update_config(config)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(
|
||||
CameraConfigUpdateEnum[field], camera
|
||||
),
|
||||
settings,
|
||||
)
|
||||
else:
|
||||
# Generic handling for global config updates
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
# Publish None for removal, actual config for add/update
|
||||
request.app.config_publisher.publisher.publish(
|
||||
body.update_topic, settings
|
||||
)
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Error parsing config. Check logs for error message.",
|
||||
"success": True,
|
||||
"message": "Config successfully updated, restart to apply",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating config: {e}")
|
||||
except Timeout:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Error updating config"}),
|
||||
status_code=500,
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Another process is currently updating the config. Please try again in a few seconds.",
|
||||
}
|
||||
),
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
request.app.frigate_config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
if field == "add":
|
||||
settings = config.cameras[camera]
|
||||
elif field == "remove":
|
||||
settings = old_config.cameras[camera]
|
||||
else:
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||
settings,
|
||||
)
|
||||
else:
|
||||
# Generic handling for global config updates
|
||||
settings = config.get_nested_object(body.update_topic)
|
||||
|
||||
# Publish None for removal, actual config for add/update
|
||||
request.app.config_publisher.publisher.publish(
|
||||
body.update_topic, settings
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Config successfully updated, restart to apply",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
||||
def vainfo():
|
||||
vainfo = vainfo_hwaccel()
|
||||
# Use LibvaGpuSelector to pick an appropriate libva device (if available)
|
||||
selected_gpu = ""
|
||||
try:
|
||||
selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or ""
|
||||
except Exception:
|
||||
selected_gpu = ""
|
||||
|
||||
# If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`.
|
||||
vainfo = vainfo_hwaccel(device_name=selected_gpu or None)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"return_code": vainfo.returncode,
|
||||
@@ -593,6 +689,98 @@ def restart():
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/media/sync",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start media sync job",
|
||||
description="""Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
|
||||
Returns 202 with job details when queued, or 409 if a job is already running.""",
|
||||
)
|
||||
def sync_media(body: MediaSyncBody = Body(...)):
|
||||
"""Start async media sync job - remove orphaned files.
|
||||
|
||||
Syncs specified media types: event snapshots, event thumbnails, review thumbnails,
|
||||
previews, exports, and/or recordings. Job runs in background; use /media/sync/current
|
||||
or /media/sync/status/{job_id} to check status.
|
||||
|
||||
Args:
|
||||
body: MediaSyncBody with dry_run flag and media_types list.
|
||||
media_types can include: 'all', 'event_snapshots', 'event_thumbnails',
|
||||
'review_thumbnails', 'previews', 'exports', 'recordings'
|
||||
|
||||
Returns:
|
||||
202 Accepted with job_id, or 409 Conflict if job already running.
|
||||
"""
|
||||
job_id = start_media_sync_job(
|
||||
dry_run=body.dry_run, media_types=body.media_types, force=body.force
|
||||
)
|
||||
|
||||
if job_id is None:
|
||||
# A job is already running
|
||||
current = get_current_media_sync_job()
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "A media sync job is already running",
|
||||
"current_job_id": current.id if current else None,
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"job": {
|
||||
"job_type": "media_sync",
|
||||
"status": JobStatusTypesEnum.queued,
|
||||
"id": job_id,
|
||||
}
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/media/sync/current",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get current media sync job",
|
||||
description="""Retrieve the current running media sync job, if any. Returns the job details
|
||||
or null when no job is active.""",
|
||||
)
|
||||
def get_media_sync_current():
|
||||
"""Get the current running media sync job, if any."""
|
||||
job = get_current_media_sync_job()
|
||||
|
||||
if job is None:
|
||||
return JSONResponse(content={"job": None}, status_code=200)
|
||||
|
||||
return JSONResponse(
|
||||
content={"job": job.to_dict()},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/media/sync/status/{job_id}",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get media sync job status",
|
||||
description="""Get status and results for the specified media sync job id. Returns 200 with
|
||||
job details including results, or 404 if the job is not found.""",
|
||||
)
|
||||
def get_media_sync_status(job_id: str):
|
||||
"""Get the status of a specific media sync job."""
|
||||
job = get_media_sync_job_by_id(job_id)
|
||||
|
||||
if job is None:
|
||||
return JSONResponse(
|
||||
content={"error": "Job not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"job": job.to_dict()},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_labels(camera: str = ""):
|
||||
try:
|
||||
@@ -642,6 +830,12 @@ def get_sub_labels(split_joined: Optional[int] = None):
|
||||
return JSONResponse(content=sub_labels)
|
||||
|
||||
|
||||
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_audio_labels():
|
||||
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||
return JSONResponse(content=labels)
|
||||
|
||||
|
||||
@router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
|
||||
def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
@@ -687,13 +881,19 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
@router.get(
|
||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||
)
|
||||
def get_recognized_license_plates(split_joined: Optional[int] = None):
|
||||
def get_recognized_license_plates(
|
||||
split_joined: Optional[int] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
query = (
|
||||
Event.select(
|
||||
SQL("json_extract(data, '$.recognized_license_plate') AS plate")
|
||||
)
|
||||
.where(SQL("json_extract(data, '$.recognized_license_plate') IS NOT NULL"))
|
||||
.where(
|
||||
(SQL("json_extract(data, '$.recognized_license_plate') IS NOT NULL"))
|
||||
& (Event.camera << allowed_cameras)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
recognized_license_plates = [row[0] for row in query.tuples()]
|
||||
|
||||
@@ -26,7 +26,7 @@ from frigate.api.defs.request.app_body import (
|
||||
AppPutRoleBody,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
|
||||
@@ -41,7 +41,7 @@ def require_admin_by_default():
|
||||
endpoints require admin access unless explicitly overridden with
|
||||
allow_public(), allow_any_authenticated(), or require_role().
|
||||
|
||||
Port 5000 (internal) always has admin role set by the /auth endpoint,
|
||||
Internal port always has admin role set by the /auth endpoint,
|
||||
so this check passes automatically for internal requests.
|
||||
|
||||
Certain paths are exempted from the global admin check because they must
|
||||
@@ -130,7 +130,7 @@ def require_admin_by_default():
|
||||
pass
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Port 5000 (internal) requests have admin role set automatically
|
||||
# Internal port requests have admin role set automatically
|
||||
role = request.headers.get("remote-role")
|
||||
if role == "admin":
|
||||
return
|
||||
@@ -143,6 +143,17 @@ def require_admin_by_default():
|
||||
return admin_checker
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""
|
||||
Helper to determine if a request is from an authenticated user.
|
||||
|
||||
Returns True if the request has a valid authenticated user (not anonymous).
|
||||
Internal port requests are considered anonymous despite having admin role.
|
||||
"""
|
||||
username = request.headers.get("remote-user")
|
||||
return username is not None and username != "anonymous"
|
||||
|
||||
|
||||
def allow_public():
|
||||
"""
|
||||
Override dependency to allow unauthenticated access to an endpoint.
|
||||
@@ -171,6 +182,7 @@ def allow_any_authenticated():
|
||||
|
||||
Rejects:
|
||||
- Requests with no remote-user header (did not pass through /auth endpoint)
|
||||
- External port requests with anonymous user (auth disabled, no proxy auth)
|
||||
|
||||
Example:
|
||||
@router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())])
|
||||
@@ -179,8 +191,14 @@ def allow_any_authenticated():
|
||||
async def auth_checker(request: Request):
|
||||
# Ensure a remote-user has been set by the /auth endpoint
|
||||
username = request.headers.get("remote-user")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Internal port requests have admin role and should be allowed
|
||||
role = request.headers.get("remote-role")
|
||||
|
||||
if role != "admin":
|
||||
if username is None or not _is_authenticated(request):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
return
|
||||
|
||||
return auth_checker
|
||||
@@ -350,21 +368,15 @@ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
|
||||
Validate password strength.
|
||||
|
||||
Returns a tuple of (is_valid, error_message).
|
||||
|
||||
Longer passwords are harder to crack than shorter complex ones.
|
||||
https://pages.nist.gov/800-63-3/sp800-63b.html
|
||||
"""
|
||||
if not password:
|
||||
return False, "Password cannot be empty"
|
||||
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
|
||||
if not any(c.isupper() for c in password):
|
||||
return False, "Password must contain at least one uppercase letter"
|
||||
|
||||
if not any(c.isdigit() for c in password):
|
||||
return False, "Password must contain at least one digit"
|
||||
|
||||
if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password):
|
||||
return False, "Password must contain at least one special character"
|
||||
if len(password) < 12:
|
||||
return False, "Password must be at least 12 characters long"
|
||||
|
||||
return True, None
|
||||
|
||||
@@ -445,10 +457,11 @@ def resolve_role(
|
||||
Determine the effective role for a request based on proxy headers and configuration.
|
||||
|
||||
Order of resolution:
|
||||
1. If a role header is defined in proxy_config.header_map.role:
|
||||
- If a role_map is configured, treat the header as group claims
|
||||
(split by proxy_config.separator) and map to roles.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
1. If a role header is defined in proxy_config.header_map.role:
|
||||
- If a role_map is configured, treat the header as group claims
|
||||
(split by proxy_config.separator) and map to roles.
|
||||
Admin matches short-circuit to admin.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||
|
||||
Args:
|
||||
@@ -498,6 +511,12 @@ def resolve_role(
|
||||
}
|
||||
logger.debug("Matched roles from role_map: %s", matched_roles)
|
||||
|
||||
# If admin matches, prioritize it to avoid accidental downgrade when
|
||||
# users belong to both admin and lower-privilege groups.
|
||||
if "admin" in matched_roles and "admin" in config_roles:
|
||||
logger.debug("Resolved role (with role_map) to 'admin'.")
|
||||
return "admin"
|
||||
|
||||
if matched_roles:
|
||||
resolved = next(
|
||||
(r for r in config_roles if r in matched_roles), validated_default
|
||||
@@ -569,12 +588,18 @@ def resolve_role(
|
||||
def auth(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||
networking_config: NetworkingConfig = request.app.frigate_config.networking
|
||||
|
||||
success_response = Response("", status_code=202)
|
||||
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = networking_config.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
# dont require auth if the request is on the internal port
|
||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed
|
||||
if int(request.headers.get("x-server-port", default=0)) == 5000:
|
||||
if int(request.headers.get("x-server-port", default=0)) == internal_port:
|
||||
success_response.headers["remote-user"] = "anonymous"
|
||||
success_response.headers["remote-role"] = "admin"
|
||||
return success_response
|
||||
@@ -800,7 +825,7 @@ def get_users():
|
||||
"/users",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create new user",
|
||||
description='Creates a new user with the specified username, password, and role. Requires admin role. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?":{} |<>).',
|
||||
description="Creates a new user with the specified username, password, and role. Requires admin role. Password must be at least 12 characters long.",
|
||||
)
|
||||
def create_user(
|
||||
request: Request,
|
||||
@@ -817,6 +842,15 @@ def create_user(
|
||||
content={"message": f"Role must be one of: {', '.join(config_roles)}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate password strength
|
||||
is_valid, error_message = validate_password_strength(body.password)
|
||||
if not is_valid:
|
||||
return JSONResponse(
|
||||
content={"message": error_message},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
role = body.role or "viewer"
|
||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||
User.insert(
|
||||
@@ -851,7 +885,7 @@ def delete_user(request: Request, username: str):
|
||||
"/users/{username}/password",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Update user password",
|
||||
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?\":{} |<>). If user changes their own password, a new JWT cookie is automatically issued.",
|
||||
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.",
|
||||
)
|
||||
async def update_password(
|
||||
request: Request,
|
||||
|
||||
@@ -848,9 +848,10 @@ async def onvif_probe(
|
||||
try:
|
||||
if isinstance(uri, str) and uri.startswith("rtsp://"):
|
||||
if username and password and "@" not in uri:
|
||||
# Inject URL-encoded credentials and add only the
|
||||
# authenticated version.
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
# Inject raw credentials and add only the
|
||||
# authenticated version. The credentials will be encoded
|
||||
# later by ffprobe_stream or the config system.
|
||||
cred = f"{username}:{password}@"
|
||||
injected = uri.replace(
|
||||
"rtsp://", f"rtsp://{cred}", 1
|
||||
)
|
||||
@@ -903,12 +904,8 @@ async def onvif_probe(
|
||||
"/cam/realmonitor?channel=1&subtype=0",
|
||||
"/11",
|
||||
]
|
||||
# Use URL-encoded credentials for pattern fallback URIs when provided
|
||||
auth_str = (
|
||||
f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
if username and password
|
||||
else ""
|
||||
)
|
||||
# Use raw credentials for pattern fallback URIs when provided
|
||||
auth_str = f"{username}:{password}@" if username and password else ""
|
||||
rtsp_port = 554
|
||||
for path in common_paths:
|
||||
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
|
||||
@@ -930,7 +927,7 @@ async def onvif_probe(
|
||||
and uri.startswith("rtsp://")
|
||||
and "@" not in uri
|
||||
):
|
||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
||||
cred = f"{username}:{password}@"
|
||||
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
|
||||
if cred_uri not in to_test:
|
||||
to_test.append(cred_uri)
|
||||
|
||||
821
frigate/api/chat.py
Normal file
821
frigate/api/chat.py
Normal file
@@ -0,0 +1,821 @@
|
||||
"""Chat and LLM tool calling APIs."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
)
|
||||
from frigate.api.defs.query.events_query_parameters import EventsQueryParams
|
||||
from frigate.api.defs.request.chat_body import ChatCompletionRequest
|
||||
from frigate.api.defs.response.chat_response import (
|
||||
ChatCompletionResponse,
|
||||
ChatMessageResponse,
|
||||
ToolCall,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.chat])
|
||||
|
||||
|
||||
def _chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, None]:
|
||||
"""Yield content in word-aware chunks for streaming."""
|
||||
if not content:
|
||||
return
|
||||
words = content.split(" ")
|
||||
current: List[str] = []
|
||||
current_len = 0
|
||||
for w in words:
|
||||
current.append(w)
|
||||
current_len += len(w) + 1
|
||||
if current_len >= chunk_size:
|
||||
yield " ".join(current) + " "
|
||||
current = []
|
||||
current_len = 0
|
||||
if current:
|
||||
yield " ".join(current)
|
||||
|
||||
|
||||
def _format_events_with_local_time(
|
||||
events_list: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Add human-readable local start/end times to each event for the LLM."""
|
||||
result = []
|
||||
for evt in events_list:
|
||||
if not isinstance(evt, dict):
|
||||
result.append(evt)
|
||||
continue
|
||||
copy_evt = dict(evt)
|
||||
try:
|
||||
start_ts = evt.get("start_time")
|
||||
end_ts = evt.get("end_time")
|
||||
if start_ts is not None:
|
||||
dt_start = datetime.fromtimestamp(start_ts)
|
||||
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
if end_ts is not None:
|
||||
dt_end = datetime.fromtimestamp(end_ts)
|
||||
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
|
||||
except (TypeError, ValueError, OSError):
|
||||
pass
|
||||
result.append(copy_evt)
|
||||
return result
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""Request model for tool execution."""
|
||||
|
||||
tool_name: str
|
||||
arguments: Dict[str, Any]
|
||||
|
||||
|
||||
def get_tool_definitions() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get OpenAI-compatible tool definitions for Frigate.
|
||||
|
||||
Returns a list of tool definitions that can be used with OpenAI-compatible
|
||||
function calling APIs.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": (
|
||||
"Search for detected objects in Frigate by camera, object label, time range, "
|
||||
"zones, and other filters. Use this to answer questions about when "
|
||||
"objects were detected, what objects appeared, or to find specific object detections. "
|
||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). "
|
||||
"When the user asks about a specific name (person, delivery company, animal, etc.), "
|
||||
"filter by sub_label only and do not set label."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to filter by (optional).",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Object label to filter by (e.g., 'person', 'package', 'car').",
|
||||
},
|
||||
"sub_label": {
|
||||
"type": "string",
|
||||
"description": "Name of a person, delivery company, animal, etc. When filtering by a specific name, use only sub_label; do not set label.",
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of zone names to filter by.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of objects to return (default: 25).",
|
||||
"default": 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current detection information for a camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when the user has included a live image (via include_live_image) or "
|
||||
"when answering questions about what is happening right now on a specific camera."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chat/tools",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get available tools",
|
||||
description="Returns OpenAI-compatible tool definitions for function calling.",
|
||||
)
|
||||
def get_tools() -> JSONResponse:
|
||||
"""Get list of available tools for LLM function calling."""
|
||||
tools = get_tool_definitions()
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
async def _execute_search_objects(
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute the search_objects tool.
|
||||
|
||||
This searches for detected objects (events) in Frigate using the same
|
||||
logic as the events API endpoint.
|
||||
"""
|
||||
# Parse after/before as server local time; convert to Unix timestamp
|
||||
after = arguments.get("after")
|
||||
before = arguments.get("before")
|
||||
|
||||
def _parse_as_local_timestamp(s: str):
|
||||
s = s.replace("Z", "").strip()[:19]
|
||||
dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
||||
return time.mktime(dt.timetuple())
|
||||
|
||||
if after:
|
||||
try:
|
||||
after = _parse_as_local_timestamp(after)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
logger.warning(f"Invalid 'after' timestamp format: {after}")
|
||||
after = None
|
||||
|
||||
if before:
|
||||
try:
|
||||
before = _parse_as_local_timestamp(before)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
logger.warning(f"Invalid 'before' timestamp format: {before}")
|
||||
before = None
|
||||
|
||||
# Convert zones array to comma-separated string if provided
|
||||
zones = arguments.get("zones")
|
||||
if isinstance(zones, list):
|
||||
zones = ",".join(zones)
|
||||
elif zones is None:
|
||||
zones = "all"
|
||||
|
||||
# Build query parameters compatible with EventsQueryParams
|
||||
query_params = EventsQueryParams(
|
||||
cameras=arguments.get("camera", "all"),
|
||||
labels=arguments.get("label", "all"),
|
||||
sub_labels=arguments.get("sub_label", "all").lower(),
|
||||
zones=zones,
|
||||
zone=zones,
|
||||
after=after,
|
||||
before=before,
|
||||
limit=arguments.get("limit", 25),
|
||||
)
|
||||
|
||||
try:
|
||||
# Call the events endpoint function directly
|
||||
# The events function is synchronous and takes params and allowed_cameras
|
||||
response = events(query_params, allowed_cameras)
|
||||
|
||||
# The response is already a JSONResponse with event data
|
||||
# Return it as-is for the LLM
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing search_objects: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Error searching objects",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat/execute",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Execute a tool",
|
||||
description="Execute a tool function call from an LLM.",
|
||||
)
|
||||
async def execute_tool(
|
||||
body: ToolExecuteRequest = Body(...),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute a tool function call.
|
||||
|
||||
This endpoint receives tool calls from LLMs and executes the corresponding
|
||||
Frigate operations, returning results in a format the LLM can understand.
|
||||
"""
|
||||
tool_name = body.tool_name
|
||||
arguments = body.arguments
|
||||
|
||||
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
||||
|
||||
if tool_name == "search_objects":
|
||||
return await _execute_search_objects(arguments, allowed_cameras)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Unknown tool: {tool_name}",
|
||||
"tool": tool_name,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
async def _execute_get_live_context(
|
||||
request: Request,
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
if camera not in allowed_cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found or access denied",
|
||||
}
|
||||
|
||||
if camera not in request.app.frigate_config.cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found",
|
||||
}
|
||||
|
||||
try:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
camera_state = frame_processor.camera_states.get(camera)
|
||||
|
||||
if camera_state is None:
|
||||
return {
|
||||
"error": f"Camera '{camera}' state not available",
|
||||
}
|
||||
|
||||
tracked_objects_dict = {}
|
||||
with camera_state.current_frame_lock:
|
||||
tracked_objects = camera_state.tracked_objects.copy()
|
||||
frame_time = camera_state.current_frame_time
|
||||
|
||||
for obj_id, tracked_obj in tracked_objects.items():
|
||||
obj_dict = tracked_obj.to_dict()
|
||||
if obj_dict.get("frame_time") == frame_time:
|
||||
tracked_objects_dict[obj_id] = {
|
||||
"label": obj_dict.get("label"),
|
||||
"zones": obj_dict.get("current_zones", []),
|
||||
"sub_label": obj_dict.get("sub_label"),
|
||||
"stationary": obj_dict.get("stationary", False),
|
||||
}
|
||||
|
||||
return {
|
||||
"camera": camera,
|
||||
"timestamp": frame_time,
|
||||
"detections": list(tracked_objects_dict.values()),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing get_live_context: {e}", exc_info=True)
|
||||
return {
|
||||
"error": "Error getting live context",
|
||||
}
|
||||
|
||||
|
||||
async def _get_live_frame_image_url(
|
||||
request: Request,
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Fetch the current live frame for a camera as a base64 data URL.
|
||||
|
||||
Returns None if the frame cannot be retrieved. Used when include_live_image
|
||||
is set to attach the image to the first user message.
|
||||
"""
|
||||
if (
|
||||
camera not in allowed_cameras
|
||||
or camera not in request.app.frigate_config.cameras
|
||||
):
|
||||
return None
|
||||
try:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
if camera not in frame_processor.camera_states:
|
||||
return None
|
||||
frame = frame_processor.get_current_frame(camera, {})
|
||||
if frame is None:
|
||||
return None
|
||||
height, width = frame.shape[:2]
|
||||
max_dimension = 1024
|
||||
if height > max_dimension or width > max_dimension:
|
||||
scale = max_dimension / max(height, width)
|
||||
frame = cv2.resize(
|
||||
frame,
|
||||
(int(width * scale), int(height * scale)),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
except Exception as e:
|
||||
logger.debug("Failed to get live frame for %s: %s", camera, e)
|
||||
return None
|
||||
|
||||
|
||||
async def _execute_tool_internal(
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
request: Request,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal helper to execute a tool and return the result as a dict.
|
||||
|
||||
This is used by the chat completion endpoint to execute tools.
|
||||
"""
|
||||
if tool_name == "search_objects":
|
||||
response = await _execute_search_objects(arguments, allowed_cameras)
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_str = response.body.decode("utf-8")
|
||||
return json.loads(body_str)
|
||||
elif hasattr(response, "content"):
|
||||
return response.content
|
||||
else:
|
||||
return {}
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logger.warning(f"Failed to extract tool result: {e}")
|
||||
return {"error": "Failed to parse tool result"}
|
||||
elif tool_name == "get_live_context":
|
||||
camera = arguments.get("camera")
|
||||
if not camera:
|
||||
logger.error(
|
||||
"Tool get_live_context failed: camera parameter is required. "
|
||||
"Arguments: %s",
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": "Camera parameter is required"}
|
||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
||||
else:
|
||||
logger.error(
|
||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context. "
|
||||
"Arguments received: %s",
|
||||
tool_name,
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
|
||||
async def _execute_pending_tools(
|
||||
pending_tool_calls: List[Dict[str, Any]],
|
||||
request: Request,
|
||||
allowed_cameras: List[str],
|
||||
) -> tuple[List[ToolCall], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Execute a list of tool calls; return (ToolCall list for API response, tool result dicts for conversation).
|
||||
"""
|
||||
tool_calls_out: List[ToolCall] = []
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
for tool_call in pending_tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = tool_call.get("arguments") or {}
|
||||
tool_call_id = tool_call["id"]
|
||||
logger.debug(
|
||||
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
|
||||
)
|
||||
try:
|
||||
tool_result = await _execute_tool_internal(
|
||||
tool_name, tool_args, request, allowed_cameras
|
||||
)
|
||||
if isinstance(tool_result, dict) and tool_result.get("error"):
|
||||
logger.error(
|
||||
"Tool call %s (id: %s) returned error: %s. Arguments: %s",
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
tool_result.get("error"),
|
||||
json.dumps(tool_args),
|
||||
)
|
||||
if tool_name == "search_objects" and isinstance(tool_result, list):
|
||||
tool_result = _format_events_with_local_time(tool_result)
|
||||
_keys = {
|
||||
"id",
|
||||
"camera",
|
||||
"label",
|
||||
"zones",
|
||||
"start_time_local",
|
||||
"end_time_local",
|
||||
"sub_label",
|
||||
"event_count",
|
||||
}
|
||||
tool_result = [
|
||||
{k: evt[k] for k in _keys if k in evt}
|
||||
for evt in tool_result
|
||||
if isinstance(evt, dict)
|
||||
]
|
||||
result_content = (
|
||||
json.dumps(tool_result)
|
||||
if isinstance(tool_result, (dict, list))
|
||||
else (tool_result if isinstance(tool_result, str) else str(tool_result))
|
||||
)
|
||||
tool_calls_out.append(
|
||||
ToolCall(name=tool_name, arguments=tool_args, response=result_content)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error executing tool %s (id: %s): %s. Arguments: %s",
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
e,
|
||||
json.dumps(tool_args),
|
||||
exc_info=True,
|
||||
)
|
||||
error_content = json.dumps({"error": f"Tool execution failed: {str(e)}"})
|
||||
tool_calls_out.append(
|
||||
ToolCall(name=tool_name, arguments=tool_args, response=error_content)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": error_content,
|
||||
}
|
||||
)
|
||||
return (tool_calls_out, tool_results)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chat/completion",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Chat completion with tool calling",
|
||||
description=(
|
||||
"Send a chat message to the configured GenAI provider with tool calling support. "
|
||||
"The LLM can call Frigate tools to answer questions about your cameras and events."
|
||||
),
|
||||
)
|
||||
async def chat_completion(
|
||||
request: Request,
|
||||
body: ChatCompletionRequest = Body(...),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""
|
||||
Chat completion endpoint with tool calling support.
|
||||
|
||||
This endpoint:
|
||||
1. Gets the configured GenAI client
|
||||
2. Gets tool definitions
|
||||
3. Sends messages + tools to LLM
|
||||
4. Handles tool_calls if present
|
||||
5. Executes tools and sends results back to LLM
|
||||
6. Repeats until final answer
|
||||
7. Returns response to user
|
||||
"""
|
||||
genai_client = request.app.genai_manager.tool_client
|
||||
if not genai_client:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "GenAI is not configured. Please configure a GenAI provider in your Frigate config.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
tools = get_tool_definitions()
|
||||
conversation = []
|
||||
|
||||
current_datetime = datetime.now()
|
||||
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
||||
current_time_str = current_datetime.strftime("%I:%M:%S %p")
|
||||
|
||||
cameras_info = []
|
||||
config = request.app.frigate_config
|
||||
for camera_id in allowed_cameras:
|
||||
if camera_id not in config.cameras:
|
||||
continue
|
||||
camera_config = config.cameras[camera_id]
|
||||
friendly_name = (
|
||||
camera_config.friendly_name
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
|
||||
cameras_section = ""
|
||||
if cameras_info:
|
||||
cameras_section = (
|
||||
"\n\nAvailable cameras:\n"
|
||||
+ "\n".join(cameras_info)
|
||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||
)
|
||||
|
||||
live_image_note = ""
|
||||
if body.include_live_image:
|
||||
live_image_note = (
|
||||
f"\n\nThe first user message includes a live image from camera "
|
||||
f"'{body.include_live_image}'. Use get_live_context for that camera to get "
|
||||
"current detection details (objects, zones) to aid in understanding the image."
|
||||
)
|
||||
|
||||
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||
|
||||
Current server local date and time: {current_date_str} at {current_time_str}
|
||||
|
||||
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
|
||||
|
||||
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
|
||||
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
|
||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||
Always be accurate with time calculations based on the current date provided.{cameras_section}{live_image_note}"""
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
}
|
||||
)
|
||||
|
||||
first_user_message_seen = False
|
||||
for msg in body.messages:
|
||||
msg_dict = {
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
}
|
||||
if msg.tool_call_id:
|
||||
msg_dict["tool_call_id"] = msg.tool_call_id
|
||||
if msg.name:
|
||||
msg_dict["name"] = msg.name
|
||||
|
||||
if (
|
||||
msg.role == "user"
|
||||
and not first_user_message_seen
|
||||
and body.include_live_image
|
||||
):
|
||||
first_user_message_seen = True
|
||||
image_url = await _get_live_frame_image_url(
|
||||
request, body.include_live_image, allowed_cameras
|
||||
)
|
||||
if image_url:
|
||||
msg_dict["content"] = [
|
||||
{"type": "text", "text": msg.content},
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
]
|
||||
|
||||
conversation.append(msg_dict)
|
||||
|
||||
tool_iterations = 0
|
||||
tool_calls: List[ToolCall] = []
|
||||
max_iterations = body.max_tool_iterations
|
||||
|
||||
logger.debug(
|
||||
f"Starting chat completion with {len(conversation)} message(s), "
|
||||
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
|
||||
)
|
||||
|
||||
# True LLM streaming when client supports it and stream requested
|
||||
if body.stream and hasattr(genai_client, "chat_with_tools_stream"):
|
||||
stream_tool_calls: List[ToolCall] = []
|
||||
stream_iterations = 0
|
||||
|
||||
async def stream_body_llm():
|
||||
nonlocal conversation, stream_tool_calls, stream_iterations
|
||||
while stream_iterations < max_iterations:
|
||||
logger.debug(
|
||||
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s)"
|
||||
)
|
||||
async for event in genai_client.chat_with_tools_stream(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
):
|
||||
kind, value = event
|
||||
if kind == "content_delta":
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "An error occurred while processing your request.",
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
return
|
||||
pending = msg.get("tool_calls")
|
||||
if pending:
|
||||
stream_iterations += 1
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
msg.get("content"), pending
|
||||
)
|
||||
)
|
||||
executed_calls, tool_results = await _execute_pending_tools(
|
||||
pending, request, allowed_cameras
|
||||
)
|
||||
stream_tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
tc.model_dump() for tc in stream_tool_calls
|
||||
],
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
break
|
||||
else:
|
||||
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
|
||||
return
|
||||
else:
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body_llm(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
try:
|
||||
while tool_iterations < max_iterations:
|
||||
logger.debug(
|
||||
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
|
||||
f"with {len(conversation)} message(s) in conversation"
|
||||
)
|
||||
response = genai_client.chat_with_tools(
|
||||
messages=conversation,
|
||||
tools=tools if tools else None,
|
||||
tool_choice="auto",
|
||||
)
|
||||
|
||||
if response.get("finish_reason") == "error":
|
||||
logger.error("GenAI client returned an error")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
build_assistant_message_for_conversation(
|
||||
response.get("content"), response.get("tool_calls")
|
||||
)
|
||||
)
|
||||
|
||||
pending_tool_calls = response.get("tool_calls")
|
||||
if not pending_tool_calls:
|
||||
logger.debug(
|
||||
f"Chat completion finished with final answer (iterations: {tool_iterations})"
|
||||
)
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
|
||||
async def stream_body() -> Any:
|
||||
if tool_calls:
|
||||
yield (
|
||||
json.dumps(
|
||||
{
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
tc.model_dump() for tc in tool_calls
|
||||
],
|
||||
}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in _chunk_content(final_content):
|
||||
yield (
|
||||
json.dumps({"type": "content", "delta": part}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_body(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
tool_iterations += 1
|
||||
logger.debug(
|
||||
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
|
||||
f"{len(pending_tool_calls)} tool(s) to execute"
|
||||
)
|
||||
executed_calls, tool_results = await _execute_pending_tools(
|
||||
pending_tool_calls, request, allowed_cameras
|
||||
)
|
||||
tool_calls.extend(executed_calls)
|
||||
conversation.extend(tool_results)
|
||||
logger.debug(
|
||||
f"Added {len(tool_results)} tool result(s) to conversation. "
|
||||
f"Continuing with next LLM call..."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Max tool iterations ({max_iterations}) reached. Returning partial response."
|
||||
)
|
||||
return JSONResponse(
|
||||
content=ChatCompletionResponse(
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content="I reached the maximum number of tool call iterations. Please try rephrasing your question.",
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason="length",
|
||||
tool_iterations=tool_iterations,
|
||||
tool_calls=tool_calls,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in chat completion: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"error": "An error occurred while processing your request.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -73,7 +73,7 @@ def get_faces():
|
||||
face_dict[name] = []
|
||||
|
||||
for file in filter(
|
||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||
lambda f: f.lower().endswith((".webp", ".png", ".jpg", ".jpeg")),
|
||||
os.listdir(face_dir),
|
||||
):
|
||||
face_dict[name].append(file)
|
||||
@@ -582,7 +582,7 @@ def get_classification_dataset(name: str):
|
||||
dataset_dict[category_name] = []
|
||||
|
||||
for file in filter(
|
||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||
lambda f: f.lower().endswith((".webp", ".png", ".jpg", ".jpeg")),
|
||||
os.listdir(category_dir),
|
||||
):
|
||||
dataset_dict[category_name].append(file)
|
||||
@@ -693,7 +693,7 @@ def get_classification_images(name: str):
|
||||
status_code=200,
|
||||
content=list(
|
||||
filter(
|
||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||
lambda f: f.lower().endswith((".webp", ".png", ".jpg", ".jpeg")),
|
||||
os.listdir(train_dir),
|
||||
)
|
||||
),
|
||||
@@ -759,15 +759,28 @@ def delete_classification_dataset_images(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
for id in list_of_ids:
|
||||
file_path = os.path.join(folder, sanitize_filename(id))
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
deleted_count += 1
|
||||
|
||||
if os.path.exists(folder) and not os.listdir(folder) and category.lower() != "none":
|
||||
os.rmdir(folder)
|
||||
|
||||
# Update training metadata to reflect deleted images
|
||||
# This ensures the dataset is marked as changed after deletion
|
||||
# (even if the total count happens to be the same after adding and deleting)
|
||||
if deleted_count > 0:
|
||||
sanitized_name = sanitize_filename(name)
|
||||
metadata = read_training_metadata(sanitized_name)
|
||||
if metadata:
|
||||
last_count = metadata.get("last_training_image_count", 0)
|
||||
updated_count = max(0, last_count - deleted_count)
|
||||
write_training_metadata(sanitized_name, updated_count)
|
||||
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted images."}),
|
||||
status_code=200,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class Extension(str, Enum):
|
||||
@@ -48,15 +47,3 @@ class MediaMjpegFeedQueryParams(BaseModel):
|
||||
mask: Optional[int] = None
|
||||
motion: Optional[int] = None
|
||||
regions: Optional[int] = None
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "utc"
|
||||
cameras: Optional[str] = "all"
|
||||
|
||||
|
||||
class MediaRecordingsAvailabilityQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
before: Union[float, SkipJsonSchema[None]] = None
|
||||
after: Union[float, SkipJsonSchema[None]] = None
|
||||
scale: int = 30
|
||||
|
||||
21
frigate/api/defs/query/recordings_query_parameters.py
Normal file
21
frigate/api/defs/query/recordings_query_parameters.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "utc"
|
||||
cameras: Optional[str] = "all"
|
||||
|
||||
|
||||
class MediaRecordingsAvailabilityQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
before: Union[float, SkipJsonSchema[None]] = None
|
||||
after: Union[float, SkipJsonSchema[None]] = None
|
||||
scale: int = 30
|
||||
|
||||
|
||||
class RecordingsDeleteQueryParams(BaseModel):
|
||||
keep: Optional[str] = None
|
||||
cameras: Optional[str] = "all"
|
||||
@@ -10,7 +10,7 @@ class ReviewQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
reviewed: int = 0
|
||||
reviewed: Union[int, SkipJsonSchema[None]] = None
|
||||
limit: Union[int, SkipJsonSchema[None]] = None
|
||||
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
|
||||
before: Union[float, SkipJsonSchema[None]] = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AppConfigSetBody(BaseModel):
|
||||
@@ -27,3 +27,16 @@ class AppPostLoginBody(BaseModel):
|
||||
|
||||
class AppPutRoleBody(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class MediaSyncBody(BaseModel):
|
||||
dry_run: bool = Field(
|
||||
default=True, description="If True, only report orphans without deleting them"
|
||||
)
|
||||
media_types: List[str] = Field(
|
||||
default=["all"],
|
||||
description="Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', 'recordings'",
|
||||
)
|
||||
force: bool = Field(
|
||||
default=False, description="If True, bypass safety threshold checks"
|
||||
)
|
||||
|
||||
45
frigate/api/defs/request/chat_body.py
Normal file
45
frigate/api/defs/request/chat_body.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Chat API request models."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""A single message in a chat conversation."""
|
||||
|
||||
role: str = Field(
|
||||
description="Message role: 'user', 'assistant', 'system', or 'tool'"
|
||||
)
|
||||
content: str = Field(description="Message content")
|
||||
tool_call_id: Optional[str] = Field(
|
||||
default=None, description="For tool messages, the ID of the tool call"
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
default=None, description="For tool messages, the tool name"
|
||||
)
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
"""Request for chat completion with tool calling."""
|
||||
|
||||
messages: list[ChatMessage] = Field(
|
||||
description="List of messages in the conversation"
|
||||
)
|
||||
max_tool_iterations: int = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="Maximum number of tool call iterations (default: 5)",
|
||||
)
|
||||
include_live_image: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"If set, the current live frame from this camera is attached to the first "
|
||||
"user message as multimodal content. Use with get_live_context for detection info."
|
||||
),
|
||||
)
|
||||
stream: bool = Field(
|
||||
default=False,
|
||||
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
|
||||
)
|
||||
@@ -41,6 +41,7 @@ class EventsCreateBody(BaseModel):
|
||||
duration: Optional[int] = 30
|
||||
include_recording: Optional[bool] = True
|
||||
draw: Optional[dict] = {}
|
||||
pre_capture: Optional[int] = None
|
||||
|
||||
|
||||
class EventsEndBody(BaseModel):
|
||||
|
||||
35
frigate/api/defs/request/export_case_body.py
Normal file
35
frigate/api/defs/request/export_case_body.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExportCaseCreateBody(BaseModel):
|
||||
"""Request body for creating a new export case."""
|
||||
|
||||
name: str = Field(max_length=100, description="Friendly name of the export case")
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Optional description of the export case"
|
||||
)
|
||||
|
||||
|
||||
class ExportCaseUpdateBody(BaseModel):
|
||||
"""Request body for updating an existing export case."""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Updated friendly name of the export case",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Updated description of the export case"
|
||||
)
|
||||
|
||||
|
||||
class ExportCaseAssignBody(BaseModel):
|
||||
"""Request body for assigning or unassigning an export to a case."""
|
||||
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=30,
|
||||
description="Case ID to assign to the export, or null to unassign",
|
||||
)
|
||||
@@ -3,18 +3,47 @@ from typing import Optional, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.record.export import (
|
||||
PlaybackFactorEnum,
|
||||
PlaybackSourceEnum,
|
||||
)
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
|
||||
|
||||
class ExportRecordingsBody(BaseModel):
|
||||
playback: PlaybackFactorEnum = Field(
|
||||
default=PlaybackFactorEnum.realtime, title="Playback factor"
|
||||
)
|
||||
source: PlaybackSourceEnum = Field(
|
||||
default=PlaybackSourceEnum.recordings, title="Playback source"
|
||||
)
|
||||
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
|
||||
image_path: Union[str, SkipJsonSchema[None]] = None
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Export case ID",
|
||||
max_length=30,
|
||||
description="ID of the export case to assign this export to",
|
||||
)
|
||||
|
||||
|
||||
class ExportRecordingsCustomBody(BaseModel):
|
||||
source: PlaybackSourceEnum = Field(
|
||||
default=PlaybackSourceEnum.recordings, title="Playback source"
|
||||
)
|
||||
name: str = Field(title="Friendly name", default=None, max_length=256)
|
||||
image_path: Union[str, SkipJsonSchema[None]] = None
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Export case ID",
|
||||
max_length=30,
|
||||
description="ID of the export case to assign this export to",
|
||||
)
|
||||
ffmpeg_input_args: Optional[str] = Field(
|
||||
default=None,
|
||||
title="FFmpeg input arguments",
|
||||
description="Custom FFmpeg input arguments. If not provided, defaults to timelapse input args.",
|
||||
)
|
||||
ffmpeg_output_args: Optional[str] = Field(
|
||||
default=None,
|
||||
title="FFmpeg output arguments",
|
||||
description="Custom FFmpeg output arguments. If not provided, defaults to timelapse output args.",
|
||||
)
|
||||
cpu_fallback: bool = Field(
|
||||
default=False,
|
||||
title="CPU Fallback",
|
||||
description="If true, retry export without hardware acceleration if the initial export fails.",
|
||||
)
|
||||
|
||||
54
frigate/api/defs/response/chat_response.py
Normal file
54
frigate/api/defs/response/chat_response.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Chat API response models."""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ToolCallInvocation(BaseModel):
|
||||
"""A tool call requested by the LLM (before execution)."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this tool call")
|
||||
name: str = Field(description="Tool name to call")
|
||||
arguments: dict[str, Any] = Field(description="Arguments for the tool call")
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""A message in the chat response."""
|
||||
|
||||
role: str = Field(description="Message role")
|
||||
content: Optional[str] = Field(
|
||||
default=None, description="Message content (None if tool calls present)"
|
||||
)
|
||||
tool_calls: Optional[list[ToolCallInvocation]] = Field(
|
||||
default=None, description="Tool calls if LLM wants to call tools"
|
||||
)
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""A tool that was executed during the completion, with its response."""
|
||||
|
||||
name: str = Field(description="Tool name that was called")
|
||||
arguments: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Arguments passed to the tool"
|
||||
)
|
||||
response: str = Field(
|
||||
default="",
|
||||
description="The response or result returned from the tool execution",
|
||||
)
|
||||
|
||||
|
||||
class ChatCompletionResponse(BaseModel):
|
||||
"""Response from chat completion."""
|
||||
|
||||
message: ChatMessageResponse = Field(description="The assistant's message")
|
||||
finish_reason: str = Field(
|
||||
description="Reason generation stopped: 'stop', 'tool_calls', 'length', 'error'"
|
||||
)
|
||||
tool_iterations: int = Field(
|
||||
default=0, description="Number of tool call iterations performed"
|
||||
)
|
||||
tool_calls: list[ToolCall] = Field(
|
||||
default_factory=list,
|
||||
description="List of tool calls that were executed during this completion",
|
||||
)
|
||||
22
frigate/api/defs/response/export_case_response.py
Normal file
22
frigate/api/defs/response/export_case_response.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExportCaseModel(BaseModel):
|
||||
"""Model representing a single export case."""
|
||||
|
||||
id: str = Field(description="Unique identifier for the export case")
|
||||
name: str = Field(description="Friendly name of the export case")
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Optional description of the export case"
|
||||
)
|
||||
created_at: float = Field(
|
||||
description="Unix timestamp when the export case was created"
|
||||
)
|
||||
updated_at: float = Field(
|
||||
description="Unix timestamp when the export case was last updated"
|
||||
)
|
||||
|
||||
|
||||
ExportCasesResponse = List[ExportCaseModel]
|
||||
@@ -15,6 +15,9 @@ class ExportModel(BaseModel):
|
||||
in_progress: bool = Field(
|
||||
description="Whether the export is currently being processed"
|
||||
)
|
||||
export_case_id: Optional[str] = Field(
|
||||
default=None, description="ID of the export case this export belongs to"
|
||||
)
|
||||
|
||||
|
||||
class StartExportResponse(BaseModel):
|
||||
|
||||
@@ -3,13 +3,15 @@ from enum import Enum
|
||||
|
||||
class Tags(Enum):
|
||||
app = "App"
|
||||
auth = "Auth"
|
||||
camera = "Camera"
|
||||
preview = "Preview"
|
||||
chat = "Chat"
|
||||
events = "Events"
|
||||
export = "Export"
|
||||
classification = "Classification"
|
||||
logs = "Logs"
|
||||
media = "Media"
|
||||
notifications = "Notifications"
|
||||
preview = "Preview"
|
||||
recordings = "Recordings"
|
||||
review = "Review"
|
||||
export = "Export"
|
||||
events = "Events"
|
||||
classification = "Classification"
|
||||
auth = "Auth"
|
||||
|
||||
@@ -69,6 +69,25 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.events])
|
||||
|
||||
|
||||
def _build_attribute_filter_clause(attributes: str):
|
||||
filtered_attributes = [
|
||||
attr.strip() for attr in attributes.split(",") if attr.strip()
|
||||
]
|
||||
attribute_clauses = []
|
||||
|
||||
for attr in filtered_attributes:
|
||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
||||
|
||||
escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1]
|
||||
if escaped_attr != attr:
|
||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*')
|
||||
|
||||
if not attribute_clauses:
|
||||
return None
|
||||
|
||||
return reduce(operator.or_, attribute_clauses)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
response_model=list[EventResponse],
|
||||
@@ -193,14 +212,9 @@ def events(
|
||||
|
||||
if attributes != "all":
|
||||
# Custom classification results are stored as data[model_name] = result_value
|
||||
filtered_attributes = attributes.split(",")
|
||||
attribute_clauses = []
|
||||
|
||||
for attr in filtered_attributes:
|
||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
||||
|
||||
attribute_clause = reduce(operator.or_, attribute_clauses)
|
||||
clauses.append(attribute_clause)
|
||||
attribute_clause = _build_attribute_filter_clause(attributes)
|
||||
if attribute_clause is not None:
|
||||
clauses.append(attribute_clause)
|
||||
|
||||
if recognized_license_plate != "all":
|
||||
filtered_recognized_license_plates = recognized_license_plate.split(",")
|
||||
@@ -508,7 +522,7 @@ def events_search(
|
||||
cameras = params.cameras
|
||||
labels = params.labels
|
||||
sub_labels = params.sub_labels
|
||||
attributes = params.attributes
|
||||
attributes = unquote(params.attributes)
|
||||
zones = params.zones
|
||||
after = params.after
|
||||
before = params.before
|
||||
@@ -607,13 +621,9 @@ def events_search(
|
||||
|
||||
if attributes != "all":
|
||||
# Custom classification results are stored as data[model_name] = result_value
|
||||
filtered_attributes = attributes.split(",")
|
||||
attribute_clauses = []
|
||||
|
||||
for attr in filtered_attributes:
|
||||
attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*')
|
||||
|
||||
event_filters.append(reduce(operator.or_, attribute_clauses))
|
||||
attribute_clause = _build_attribute_filter_clause(attributes)
|
||||
if attribute_clause is not None:
|
||||
event_filters.append(attribute_clause)
|
||||
|
||||
if zones != "all":
|
||||
zone_clauses = []
|
||||
@@ -1772,6 +1782,7 @@ def create_event(
|
||||
body.duration,
|
||||
"api",
|
||||
body.draw,
|
||||
body.pre_capture,
|
||||
),
|
||||
EventMetadataTypeEnum.manual_event_create.value,
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ import logging
|
||||
import random
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathvalidate import sanitize_filepath
|
||||
from peewee import DoesNotExist
|
||||
@@ -19,8 +19,20 @@ from frigate.api.auth import (
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
|
||||
from frigate.api.defs.request.export_case_body import (
|
||||
ExportCaseAssignBody,
|
||||
ExportCaseCreateBody,
|
||||
ExportCaseUpdateBody,
|
||||
)
|
||||
from frigate.api.defs.request.export_recordings_body import (
|
||||
ExportRecordingsBody,
|
||||
ExportRecordingsCustomBody,
|
||||
)
|
||||
from frigate.api.defs.request.export_rename_body import ExportRenameBody
|
||||
from frigate.api.defs.response.export_case_response import (
|
||||
ExportCaseModel,
|
||||
ExportCasesResponse,
|
||||
)
|
||||
from frigate.api.defs.response.export_response import (
|
||||
ExportModel,
|
||||
ExportsResponse,
|
||||
@@ -29,9 +41,9 @@ from frigate.api.defs.response.export_response import (
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import CLIPS_DIR, EXPORT_DIR
|
||||
from frigate.models import Export, Previews, Recordings
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
PlaybackFactorEnum,
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
PlaybackSourceEnum,
|
||||
RecordingExporter,
|
||||
)
|
||||
@@ -52,17 +64,182 @@ router = APIRouter(tags=[Tags.export])
|
||||
)
|
||||
def get_exports(
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
export_case_id: Optional[str] = None,
|
||||
cameras: Optional[str] = Query(default="all"),
|
||||
start_date: Optional[float] = None,
|
||||
end_date: Optional[float] = None,
|
||||
):
|
||||
exports = (
|
||||
Export.select()
|
||||
.where(Export.camera << allowed_cameras)
|
||||
.order_by(Export.date.desc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
query = Export.select().where(Export.camera << allowed_cameras)
|
||||
|
||||
if export_case_id is not None:
|
||||
if export_case_id == "unassigned":
|
||||
query = query.where(Export.export_case.is_null(True))
|
||||
else:
|
||||
query = query.where(Export.export_case == export_case_id)
|
||||
|
||||
if cameras and cameras != "all":
|
||||
requested = set(cameras.split(","))
|
||||
filtered_cameras = list(requested.intersection(allowed_cameras))
|
||||
if not filtered_cameras:
|
||||
return JSONResponse(content=[])
|
||||
query = query.where(Export.camera << filtered_cameras)
|
||||
|
||||
if start_date is not None:
|
||||
query = query.where(Export.date >= start_date)
|
||||
|
||||
if end_date is not None:
|
||||
query = query.where(Export.date <= end_date)
|
||||
|
||||
exports = query.order_by(Export.date.desc()).dicts().iterator()
|
||||
return JSONResponse(content=[e for e in exports])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cases",
|
||||
response_model=ExportCasesResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get export cases",
|
||||
description="Gets all export cases from the database.",
|
||||
)
|
||||
def get_export_cases():
|
||||
cases = (
|
||||
ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator()
|
||||
)
|
||||
return JSONResponse(content=[c for c in cases])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cases",
|
||||
response_model=ExportCaseModel,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create export case",
|
||||
description="Creates a new export case.",
|
||||
)
|
||||
def create_export_case(body: ExportCaseCreateBody):
|
||||
case = ExportCase.create(
|
||||
id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
created_at=Path().stat().st_mtime,
|
||||
updated_at=Path().stat().st_mtime,
|
||||
)
|
||||
return JSONResponse(content=model_to_dict(case))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cases/{case_id}",
|
||||
response_model=ExportCaseModel,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get a single export case",
|
||||
description="Gets a specific export case by ID.",
|
||||
)
|
||||
def get_export_case(case_id: str):
|
||||
try:
|
||||
case = ExportCase.get(ExportCase.id == case_id)
|
||||
return JSONResponse(content=model_to_dict(case))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/cases/{case_id}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Update export case",
|
||||
description="Updates an existing export case.",
|
||||
)
|
||||
def update_export_case(case_id: str, body: ExportCaseUpdateBody):
|
||||
try:
|
||||
case = ExportCase.get(ExportCase.id == case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if body.name is not None:
|
||||
case.name = body.name
|
||||
if body.description is not None:
|
||||
case.description = body.description
|
||||
|
||||
case.save()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Successfully updated export case."}
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cases/{case_id}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete export case",
|
||||
description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """,
|
||||
)
|
||||
def delete_export_case(case_id: str):
|
||||
try:
|
||||
case = ExportCase.get(ExportCase.id == case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Unassign exports from this case but keep the exports themselves
|
||||
Export.update(export_case=None).where(Export.export_case == case).execute()
|
||||
|
||||
case.delete_instance()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Successfully deleted export case."}
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/export/{export_id}/case",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Assign export to case",
|
||||
description=(
|
||||
"Assigns an export to a case, or unassigns it if export_case_id is null."
|
||||
),
|
||||
)
|
||||
async def assign_export_case(
|
||||
export_id: str,
|
||||
body: ExportCaseAssignBody,
|
||||
request: Request,
|
||||
):
|
||||
try:
|
||||
export: Export = Export.get(Export.id == export_id)
|
||||
await require_camera_access(export.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export not found."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if body.export_case_id is not None:
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == body.export_case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found."},
|
||||
status_code=404,
|
||||
)
|
||||
export.export_case = body.export_case_id
|
||||
else:
|
||||
export.export_case = None
|
||||
|
||||
export.save()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Successfully updated export case."}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export/{camera_name}/start/{start_time}/end/{end_time}",
|
||||
response_model=StartExportResponse,
|
||||
@@ -88,11 +265,20 @@ def export_recording(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
playback_factor = body.playback
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
if export_case_id is not None:
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == export_case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Ensure that existing_image is a valid path
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
return JSONResponse(
|
||||
@@ -151,16 +337,12 @@ def export_recording(
|
||||
existing_image,
|
||||
int(start_time),
|
||||
int(end_time),
|
||||
(
|
||||
PlaybackFactorEnum[playback_factor]
|
||||
if playback_factor in PlaybackFactorEnum.__members__.values()
|
||||
else PlaybackFactorEnum.realtime
|
||||
),
|
||||
(
|
||||
PlaybackSourceEnum[playback_source]
|
||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
||||
else PlaybackSourceEnum.recordings
|
||||
),
|
||||
export_case_id,
|
||||
)
|
||||
exporter.start()
|
||||
return JSONResponse(
|
||||
@@ -271,6 +453,138 @@ async def export_delete(event_id: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}",
|
||||
response_model=StartExportResponse,
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
summary="Start custom recording export",
|
||||
description="""Starts an export of a recording for the specified time range using custom FFmpeg arguments.
|
||||
The export can be from recordings or preview footage. Returns the export ID if
|
||||
successful, or an error message if the camera is invalid or no recordings/previews
|
||||
are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided,
|
||||
defaults to timelapse export settings.""",
|
||||
)
|
||||
def export_recording_custom(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
body: ExportRecordingsCustomBody,
|
||||
):
|
||||
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"{camera_name} is not a valid camera."}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
||||
ffmpeg_input_args = body.ffmpeg_input_args
|
||||
ffmpeg_output_args = body.ffmpeg_output_args
|
||||
cpu_fallback = body.cpu_fallback
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
if export_case_id is not None:
|
||||
try:
|
||||
ExportCase.get(ExportCase.id == export_case_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export case not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Ensure that existing_image is a valid path
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Invalid image path"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if playback_source == "recordings":
|
||||
recordings_count = (
|
||||
Recordings.select()
|
||||
.where(
|
||||
Recordings.start_time.between(start_time, end_time)
|
||||
| Recordings.end_time.between(start_time, end_time)
|
||||
| (
|
||||
(start_time > Recordings.start_time)
|
||||
& (end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
|
||||
if recordings_count <= 0:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No recordings found for time range"}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
else:
|
||||
previews_count = (
|
||||
Previews.select()
|
||||
.where(
|
||||
Previews.start_time.between(start_time, end_time)
|
||||
| Previews.end_time.between(start_time, end_time)
|
||||
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.count()
|
||||
)
|
||||
|
||||
if not is_current_hour(start_time) and previews_count <= 0:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": "No previews found for time range"}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
|
||||
|
||||
# Set default values if not provided (timelapse defaults)
|
||||
if ffmpeg_input_args is None:
|
||||
ffmpeg_input_args = ""
|
||||
|
||||
if ffmpeg_output_args is None:
|
||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||
|
||||
exporter = RecordingExporter(
|
||||
request.app.frigate_config,
|
||||
export_id,
|
||||
camera_name,
|
||||
friendly_name,
|
||||
existing_image,
|
||||
int(start_time),
|
||||
int(end_time),
|
||||
(
|
||||
PlaybackSourceEnum[playback_source]
|
||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
||||
else PlaybackSourceEnum.recordings
|
||||
),
|
||||
export_case_id,
|
||||
ffmpeg_input_args,
|
||||
ffmpeg_output_args,
|
||||
cpu_fallback,
|
||||
)
|
||||
exporter.start()
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Starting export of recording.",
|
||||
"export_id": export_id,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{export_id}",
|
||||
response_model=ExportModel,
|
||||
|
||||
@@ -16,12 +16,14 @@ from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
classification,
|
||||
event,
|
||||
export,
|
||||
media,
|
||||
notification,
|
||||
preview,
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||
@@ -31,6 +33,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.storage import StorageMaintainer
|
||||
@@ -120,6 +123,7 @@ def create_fastapi_app(
|
||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||
app.include_router(auth.router)
|
||||
app.include_router(camera.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(classification.router)
|
||||
app.include_router(review.router)
|
||||
app.include_router(main_app.router)
|
||||
@@ -128,8 +132,10 @@ def create_fastapi_app(
|
||||
app.include_router(export.router)
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(record.router)
|
||||
# App Properties
|
||||
app.frigate_config = frigate_config
|
||||
app.genai_manager = GenAIClientManager(frigate_config)
|
||||
app.embeddings = embeddings
|
||||
app.detected_frames_processor = detected_frames_processor
|
||||
app.storage_maintainer = storage_maintainer
|
||||
|
||||
@@ -8,9 +8,8 @@ import os
|
||||
import subprocess as sp
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import reduce
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any, List
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
@@ -19,12 +18,11 @@ import pytz
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist, fn, operator
|
||||
from peewee import DoesNotExist, fn
|
||||
from tzlocal import get_localzone_name
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
)
|
||||
from frigate.api.defs.query.media_query_parameters import (
|
||||
@@ -32,8 +30,6 @@ from frigate.api.defs.query.media_query_parameters import (
|
||||
MediaEventsSnapshotQueryParams,
|
||||
MediaLatestFrameQueryParams,
|
||||
MediaMjpegFeedQueryParams,
|
||||
MediaRecordingsAvailabilityQueryParams,
|
||||
MediaRecordingsSummaryQueryParams,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.camera.state import CameraState
|
||||
@@ -44,13 +40,12 @@ from frigate.const import (
|
||||
INSTALL_DIR,
|
||||
MAX_SEGMENT_DURATION,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
RECORD_DIR,
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||
from frigate.output.preview import get_most_recent_preview_frame
|
||||
from frigate.track.object_processing import TrackedObjectProcessor
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import get_image_from_recording
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,7 +126,9 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
|
||||
"/{camera_name}/latest.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
description="Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline.",
|
||||
)
|
||||
async def latest_frame(
|
||||
request: Request,
|
||||
@@ -165,20 +162,37 @@ async def latest_frame(
|
||||
or 10
|
||||
)
|
||||
|
||||
is_offline = False
|
||||
if frame is None or datetime.now().timestamp() > (
|
||||
frame_processor.get_current_frame_time(camera_name) + retry_interval
|
||||
):
|
||||
if request.app.camera_error_image is None:
|
||||
error_image = glob.glob(
|
||||
os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg")
|
||||
)
|
||||
last_frame_time = frame_processor.get_current_frame_time(camera_name)
|
||||
preview_path = get_most_recent_preview_frame(
|
||||
camera_name, before=last_frame_time
|
||||
)
|
||||
|
||||
if len(error_image) > 0:
|
||||
request.app.camera_error_image = cv2.imread(
|
||||
error_image[0], cv2.IMREAD_UNCHANGED
|
||||
if preview_path:
|
||||
logger.debug(f"Using most recent preview frame for {camera_name}")
|
||||
frame = cv2.imread(preview_path, cv2.IMREAD_UNCHANGED)
|
||||
|
||||
if frame is not None:
|
||||
is_offline = True
|
||||
|
||||
if frame is None or not is_offline:
|
||||
logger.debug(
|
||||
f"No live or preview frame available for {camera_name}. Using error image."
|
||||
)
|
||||
if request.app.camera_error_image is None:
|
||||
error_image = glob.glob(
|
||||
os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg")
|
||||
)
|
||||
|
||||
frame = request.app.camera_error_image
|
||||
if len(error_image) > 0:
|
||||
request.app.camera_error_image = cv2.imread(
|
||||
error_image[0], cv2.IMREAD_UNCHANGED
|
||||
)
|
||||
|
||||
frame = request.app.camera_error_image
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
@@ -200,14 +214,18 @@ async def latest_frame(
|
||||
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
_, img = cv2.imencode(f".{extension.value}", frame, quality_params)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-store" if not params.store else "private, max-age=60",
|
||||
}
|
||||
|
||||
if is_offline:
|
||||
headers["X-Frigate-Offline"] = "true"
|
||||
|
||||
return Response(
|
||||
content=img.tobytes(),
|
||||
media_type=extension.get_mime_type(),
|
||||
headers={
|
||||
"Cache-Control": "no-store"
|
||||
if not params.store
|
||||
else "private, max-age=60",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
elif (
|
||||
camera_name == "birdseye"
|
||||
@@ -397,333 +415,6 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||
"storage"
|
||||
][RECORD_DIR]
|
||||
|
||||
if not recording_stats:
|
||||
return JSONResponse({})
|
||||
|
||||
total_mb = recording_stats["total"]
|
||||
|
||||
camera_usages: dict[str, dict] = (
|
||||
request.app.storage_maintainer.calculate_camera_usages()
|
||||
)
|
||||
|
||||
for camera_name in camera_usages.keys():
|
||||
if camera_usages.get(camera_name, {}).get("usage"):
|
||||
camera_usages[camera_name]["usage_percent"] = (
|
||||
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
|
||||
) * 100
|
||||
|
||||
return JSONResponse(content=camera_usages)
|
||||
|
||||
|
||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||
def all_recordings_summary(
|
||||
request: Request,
|
||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Returns true/false by day indicating if recordings exist"""
|
||||
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
time_range_query = (
|
||||
Recordings.select(
|
||||
fn.MIN(Recordings.start_time).alias("min_time"),
|
||||
fn.MAX(Recordings.start_time).alias("max_time"),
|
||||
)
|
||||
.where(Recordings.camera << camera_list)
|
||||
.dicts()
|
||||
.get()
|
||||
)
|
||||
|
||||
min_time = time_range_query.get("min_time")
|
||||
max_time = time_range_query.get("max_time")
|
||||
|
||||
if min_time is None or max_time is None:
|
||||
return JSONResponse(content={})
|
||||
|
||||
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
|
||||
|
||||
days: dict[str, bool] = {}
|
||||
|
||||
for period_start, period_end, period_offset in dst_periods:
|
||||
hours_offset = int(period_offset / 60 / 60)
|
||||
minutes_offset = int(period_offset / 60 - hours_offset * 60)
|
||||
period_hour_modifier = f"{hours_offset} hour"
|
||||
period_minute_modifier = f"{minutes_offset} minute"
|
||||
|
||||
period_query = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("day")
|
||||
)
|
||||
.where(
|
||||
(Recordings.camera << camera_list)
|
||||
& (Recordings.end_time >= period_start)
|
||||
& (Recordings.start_time <= period_end)
|
||||
)
|
||||
.group_by(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
for g in period_query:
|
||||
days[g.day] = True
|
||||
|
||||
return JSONResponse(content=dict(sorted(days.items())))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
"""Returns hourly summary for recordings of given camera"""
|
||||
|
||||
time_range_query = (
|
||||
Recordings.select(
|
||||
fn.MIN(Recordings.start_time).alias("min_time"),
|
||||
fn.MAX(Recordings.start_time).alias("max_time"),
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.dicts()
|
||||
.get()
|
||||
)
|
||||
|
||||
min_time = time_range_query.get("min_time")
|
||||
max_time = time_range_query.get("max_time")
|
||||
|
||||
days: dict[str, dict] = {}
|
||||
|
||||
if min_time is None or max_time is None:
|
||||
return JSONResponse(content=list(days.values()))
|
||||
|
||||
dst_periods = get_dst_transitions(timezone, min_time, max_time)
|
||||
|
||||
for period_start, period_end, period_offset in dst_periods:
|
||||
hours_offset = int(period_offset / 60 / 60)
|
||||
minutes_offset = int(period_offset / 60 - hours_offset * 60)
|
||||
period_hour_modifier = f"{hours_offset} hour"
|
||||
period_minute_modifier = f"{minutes_offset} minute"
|
||||
|
||||
recording_groups = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("hour"),
|
||||
fn.SUM(Recordings.duration).alias("duration"),
|
||||
fn.SUM(Recordings.motion).alias("motion"),
|
||||
fn.SUM(Recordings.objects).alias("objects"),
|
||||
)
|
||||
.where(
|
||||
(Recordings.camera == camera_name)
|
||||
& (Recordings.end_time >= period_start)
|
||||
& (Recordings.start_time <= period_end)
|
||||
)
|
||||
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_groups = (
|
||||
Event.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Event.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("hour"),
|
||||
fn.COUNT(Event.id).alias("count"),
|
||||
)
|
||||
.where(Event.camera == camera_name, Event.has_clip)
|
||||
.where(
|
||||
(Event.start_time >= period_start) & (Event.start_time <= period_end)
|
||||
)
|
||||
.group_by((Event.start_time + period_offset).cast("int") / 3600)
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_map = {g.hour: g.count for g in event_groups}
|
||||
|
||||
for recording_group in recording_groups:
|
||||
parts = recording_group.hour.split()
|
||||
hour = parts[1]
|
||||
day = parts[0]
|
||||
events_count = event_map.get(recording_group.hour, 0)
|
||||
hour_data = {
|
||||
"hour": hour,
|
||||
"events": events_count,
|
||||
"motion": recording_group.motion,
|
||||
"objects": recording_group.objects,
|
||||
"duration": round(recording_group.duration),
|
||||
}
|
||||
if day in days:
|
||||
# merge counts if already present (edge-case at DST boundary)
|
||||
days[day]["events"] += events_count or 0
|
||||
days[day]["hours"].append(hour_data)
|
||||
else:
|
||||
days[day] = {
|
||||
"events": events_count or 0,
|
||||
"hours": [hour_data],
|
||||
"day": day,
|
||||
}
|
||||
|
||||
return JSONResponse(content=list(days.values()))
|
||||
|
||||
|
||||
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||
async def recordings(
|
||||
camera_name: str,
|
||||
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
||||
before: float = datetime.now().timestamp(),
|
||||
):
|
||||
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.motion,
|
||||
Recordings.objects,
|
||||
Recordings.duration,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera == camera_name,
|
||||
Recordings.end_time >= after,
|
||||
Recordings.start_time <= before,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return JSONResponse(content=list(recordings))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recordings/unavailable",
|
||||
response_model=list[dict],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def no_recordings(
|
||||
request: Request,
|
||||
params: MediaRecordingsAvailabilityQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get time ranges with no recordings."""
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
cameras = ",".join(filtered)
|
||||
else:
|
||||
cameras = allowed_cameras
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(Recordings.start_time, Recordings.end_time)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Convert recordings to list of (start, end) tuples
|
||||
recordings = [(r["start_time"], r["end_time"]) for r in data]
|
||||
|
||||
# Iterate through time segments and check if each has any recording
|
||||
no_recording_segments = []
|
||||
current = after
|
||||
current_gap_start = None
|
||||
|
||||
while current < before:
|
||||
segment_end = min(current + scale, before)
|
||||
|
||||
# Check if this segment overlaps with any recording
|
||||
has_recording = any(
|
||||
rec_start < segment_end and rec_end > current
|
||||
for rec_start, rec_end in recordings
|
||||
)
|
||||
|
||||
if not has_recording:
|
||||
# This segment has no recordings
|
||||
if current_gap_start is None:
|
||||
current_gap_start = current # Start a new gap
|
||||
else:
|
||||
# This segment has recordings
|
||||
if current_gap_start is not None:
|
||||
# End the current gap and append it
|
||||
no_recording_segments.append(
|
||||
{"start_time": int(current_gap_start), "end_time": int(current)}
|
||||
)
|
||||
current_gap_start = None
|
||||
|
||||
current = segment_end
|
||||
|
||||
# Append the last gap if it exists
|
||||
if current_gap_start is not None:
|
||||
no_recording_segments.append(
|
||||
{"start_time": int(current_gap_start), "end_time": int(before)}
|
||||
)
|
||||
|
||||
return JSONResponse(content=no_recording_segments)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
@@ -1046,6 +737,7 @@ async def event_snapshot(
|
||||
):
|
||||
event_complete = False
|
||||
jpg_bytes = None
|
||||
frame_time = 0
|
||||
try:
|
||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||
event_complete = True
|
||||
@@ -1070,7 +762,7 @@ async def event_snapshot(
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
jpg_bytes = tracked_obj.get_img_bytes(
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=params.timestamp,
|
||||
bounding_box=params.bbox,
|
||||
@@ -1099,6 +791,7 @@ async def event_snapshot(
|
||||
headers = {
|
||||
"Content-Type": "image/jpeg",
|
||||
"Cache-Control": "private, max-age=31536000" if event_complete else "no-store",
|
||||
"X-Frame-Time": str(frame_time),
|
||||
}
|
||||
|
||||
if params.download:
|
||||
|
||||
479
frigate/api/record.py
Normal file
479
frigate/api/record.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""Recording APIs."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import Path as PathParam
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import fn, operator
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.recordings_query_parameters import (
|
||||
MediaRecordingsAvailabilityQueryParams,
|
||||
MediaRecordingsSummaryQueryParams,
|
||||
RecordingsDeleteQueryParams,
|
||||
)
|
||||
from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.recordings])
|
||||
|
||||
|
||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||
"storage"
|
||||
][RECORD_DIR]
|
||||
|
||||
if not recording_stats:
|
||||
return JSONResponse({})
|
||||
|
||||
total_mb = recording_stats["total"]
|
||||
|
||||
camera_usages: dict[str, dict] = (
|
||||
request.app.storage_maintainer.calculate_camera_usages()
|
||||
)
|
||||
|
||||
for camera_name in camera_usages.keys():
|
||||
if camera_usages.get(camera_name, {}).get("usage"):
|
||||
camera_usages[camera_name]["usage_percent"] = (
|
||||
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
|
||||
) * 100
|
||||
|
||||
return JSONResponse(content=camera_usages)
|
||||
|
||||
|
||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||
def all_recordings_summary(
|
||||
request: Request,
|
||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Returns true/false by day indicating if recordings exist"""
|
||||
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
time_range_query = (
|
||||
Recordings.select(
|
||||
fn.MIN(Recordings.start_time).alias("min_time"),
|
||||
fn.MAX(Recordings.start_time).alias("max_time"),
|
||||
)
|
||||
.where(Recordings.camera << camera_list)
|
||||
.dicts()
|
||||
.get()
|
||||
)
|
||||
|
||||
min_time = time_range_query.get("min_time")
|
||||
max_time = time_range_query.get("max_time")
|
||||
|
||||
if min_time is None or max_time is None:
|
||||
return JSONResponse(content={})
|
||||
|
||||
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
|
||||
|
||||
days: dict[str, bool] = {}
|
||||
|
||||
for period_start, period_end, period_offset in dst_periods:
|
||||
hours_offset = int(period_offset / 60 / 60)
|
||||
minutes_offset = int(period_offset / 60 - hours_offset * 60)
|
||||
period_hour_modifier = f"{hours_offset} hour"
|
||||
period_minute_modifier = f"{minutes_offset} minute"
|
||||
|
||||
period_query = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("day")
|
||||
)
|
||||
.where(
|
||||
(Recordings.camera << camera_list)
|
||||
& (Recordings.end_time >= period_start)
|
||||
& (Recordings.start_time <= period_end)
|
||||
)
|
||||
.group_by(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
for g in period_query:
|
||||
days[g.day] = True
|
||||
|
||||
return JSONResponse(content=dict(sorted(days.items())))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def recordings_summary(camera_name: str, timezone: str = "utc"):
|
||||
"""Returns hourly summary for recordings of given camera"""
|
||||
|
||||
time_range_query = (
|
||||
Recordings.select(
|
||||
fn.MIN(Recordings.start_time).alias("min_time"),
|
||||
fn.MAX(Recordings.start_time).alias("max_time"),
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.dicts()
|
||||
.get()
|
||||
)
|
||||
|
||||
min_time = time_range_query.get("min_time")
|
||||
max_time = time_range_query.get("max_time")
|
||||
|
||||
days: dict[str, dict] = {}
|
||||
|
||||
if min_time is None or max_time is None:
|
||||
return JSONResponse(content=list(days.values()))
|
||||
|
||||
dst_periods = get_dst_transitions(timezone, min_time, max_time)
|
||||
|
||||
for period_start, period_end, period_offset in dst_periods:
|
||||
hours_offset = int(period_offset / 60 / 60)
|
||||
minutes_offset = int(period_offset / 60 - hours_offset * 60)
|
||||
period_hour_modifier = f"{hours_offset} hour"
|
||||
period_minute_modifier = f"{minutes_offset} minute"
|
||||
|
||||
recording_groups = (
|
||||
Recordings.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Recordings.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("hour"),
|
||||
fn.SUM(Recordings.duration).alias("duration"),
|
||||
fn.SUM(Recordings.motion).alias("motion"),
|
||||
fn.SUM(Recordings.objects).alias("objects"),
|
||||
)
|
||||
.where(
|
||||
(Recordings.camera == camera_name)
|
||||
& (Recordings.end_time >= period_start)
|
||||
& (Recordings.start_time <= period_end)
|
||||
)
|
||||
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_groups = (
|
||||
Event.select(
|
||||
fn.strftime(
|
||||
"%Y-%m-%d %H",
|
||||
fn.datetime(
|
||||
Event.start_time,
|
||||
"unixepoch",
|
||||
period_hour_modifier,
|
||||
period_minute_modifier,
|
||||
),
|
||||
).alias("hour"),
|
||||
fn.COUNT(Event.id).alias("count"),
|
||||
)
|
||||
.where(Event.camera == camera_name, Event.has_clip)
|
||||
.where(
|
||||
(Event.start_time >= period_start) & (Event.start_time <= period_end)
|
||||
)
|
||||
.group_by((Event.start_time + period_offset).cast("int") / 3600)
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
event_map = {g.hour: g.count for g in event_groups}
|
||||
|
||||
for recording_group in recording_groups:
|
||||
parts = recording_group.hour.split()
|
||||
hour = parts[1]
|
||||
day = parts[0]
|
||||
events_count = event_map.get(recording_group.hour, 0)
|
||||
hour_data = {
|
||||
"hour": hour,
|
||||
"events": events_count,
|
||||
"motion": recording_group.motion,
|
||||
"objects": recording_group.objects,
|
||||
"duration": round(recording_group.duration),
|
||||
}
|
||||
if day in days:
|
||||
# merge counts if already present (edge-case at DST boundary)
|
||||
days[day]["events"] += events_count or 0
|
||||
days[day]["hours"].append(hour_data)
|
||||
else:
|
||||
days[day] = {
|
||||
"events": events_count or 0,
|
||||
"hours": [hour_data],
|
||||
"day": day,
|
||||
}
|
||||
|
||||
return JSONResponse(content=list(days.values()))
|
||||
|
||||
|
||||
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
|
||||
async def recordings(
|
||||
camera_name: str,
|
||||
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
|
||||
before: float = datetime.now().timestamp(),
|
||||
):
|
||||
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
Recordings.segment_size,
|
||||
Recordings.motion,
|
||||
Recordings.objects,
|
||||
Recordings.duration,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera == camera_name,
|
||||
Recordings.end_time >= after,
|
||||
Recordings.start_time <= before,
|
||||
)
|
||||
.order_by(Recordings.start_time)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return JSONResponse(content=list(recordings))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recordings/unavailable",
|
||||
response_model=list[dict],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def no_recordings(
|
||||
request: Request,
|
||||
params: MediaRecordingsAvailabilityQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get time ranges with no recordings."""
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
cameras = ",".join(filtered)
|
||||
else:
|
||||
cameras = allowed_cameras
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(Recordings.start_time, Recordings.end_time)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Convert recordings to list of (start, end) tuples
|
||||
recordings = [(r["start_time"], r["end_time"]) for r in data]
|
||||
|
||||
# Iterate through time segments and check if each has any recording
|
||||
no_recording_segments = []
|
||||
current = after
|
||||
current_gap_start = None
|
||||
|
||||
while current < before:
|
||||
segment_end = min(current + scale, before)
|
||||
|
||||
# Check if this segment overlaps with any recording
|
||||
has_recording = any(
|
||||
rec_start < segment_end and rec_end > current
|
||||
for rec_start, rec_end in recordings
|
||||
)
|
||||
|
||||
if not has_recording:
|
||||
# This segment has no recordings
|
||||
if current_gap_start is None:
|
||||
current_gap_start = current # Start a new gap
|
||||
else:
|
||||
# This segment has recordings
|
||||
if current_gap_start is not None:
|
||||
# End the current gap and append it
|
||||
no_recording_segments.append(
|
||||
{"start_time": int(current_gap_start), "end_time": int(current)}
|
||||
)
|
||||
current_gap_start = None
|
||||
|
||||
current = segment_end
|
||||
|
||||
# Append the last gap if it exists
|
||||
if current_gap_start is not None:
|
||||
no_recording_segments.append(
|
||||
{"start_time": int(current_gap_start), "end_time": int(before)}
|
||||
)
|
||||
|
||||
return JSONResponse(content=no_recording_segments)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/recordings/start/{start}/end/{end}",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Delete recordings",
|
||||
description="""Deletes recordings within the specified time range.
|
||||
Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes.
|
||||
""",
|
||||
)
|
||||
async def delete_recordings(
|
||||
start: float = PathParam(..., description="Start timestamp (unix)"),
|
||||
end: float = PathParam(..., description="End timestamp (unix)"),
|
||||
params: RecordingsDeleteQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Delete recordings in the specified time range."""
|
||||
if start >= end:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Start time must be less than end time.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
cameras = params.cameras
|
||||
|
||||
if cameras != "all":
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
|
||||
if not filtered:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "No valid cameras found in the request.",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
# Parse keep parameter
|
||||
keep_set = set()
|
||||
|
||||
if params.keep:
|
||||
keep_set = set(params.keep.split(","))
|
||||
|
||||
# Build query to find overlapping recordings
|
||||
clauses = [
|
||||
(
|
||||
Recordings.start_time.between(start, end)
|
||||
| Recordings.end_time.between(start, end)
|
||||
| ((start > Recordings.start_time) & (end < Recordings.end_time))
|
||||
),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
|
||||
keep_clauses = []
|
||||
|
||||
if "motion" in keep_set:
|
||||
keep_clauses.append(Recordings.motion.is_null(False) & (Recordings.motion > 0))
|
||||
|
||||
if "object" in keep_set:
|
||||
keep_clauses.append(
|
||||
Recordings.objects.is_null(False) & (Recordings.objects > 0)
|
||||
)
|
||||
|
||||
if "audio" in keep_set:
|
||||
keep_clauses.append(Recordings.dBFS.is_null(False))
|
||||
|
||||
if keep_clauses:
|
||||
keep_condition = reduce(operator.or_, keep_clauses)
|
||||
clauses.append(~keep_condition)
|
||||
|
||||
recordings_to_delete = (
|
||||
Recordings.select(Recordings.id, Recordings.path)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
recording_ids = []
|
||||
deleted_count = 0
|
||||
error_count = 0
|
||||
|
||||
for recording in recordings_to_delete:
|
||||
recording_ids.append(recording["id"])
|
||||
|
||||
try:
|
||||
Path(recording["path"]).unlink(missing_ok=True)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
|
||||
error_count += 1
|
||||
|
||||
if recording_ids:
|
||||
max_deletes = 100000
|
||||
recording_ids_list = list(recording_ids)
|
||||
|
||||
for i in range(0, len(recording_ids_list), max_deletes):
|
||||
Recordings.delete().where(
|
||||
Recordings.id << recording_ids_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
message = f"Successfully deleted {deleted_count} recording(s)."
|
||||
|
||||
if error_count > 0:
|
||||
message += f" {error_count} file deletion error(s) occurred."
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": message},
|
||||
status_code=200,
|
||||
)
|
||||
@@ -33,7 +33,6 @@ from frigate.api.defs.response.review_response import (
|
||||
ReviewSummaryResponse,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
|
||||
from frigate.review.types import SeverityEnum
|
||||
@@ -144,6 +143,8 @@ async def review(
|
||||
(UserReviewStatus.has_been_reviewed == False)
|
||||
| (UserReviewStatus.has_been_reviewed.is_null())
|
||||
)
|
||||
elif reviewed == 1:
|
||||
review_query = review_query.where(UserReviewStatus.has_been_reviewed == True)
|
||||
|
||||
# Apply ordering and limit
|
||||
review_query = (
|
||||
@@ -745,9 +746,7 @@ async def set_not_reviewed(
|
||||
description="Use GenAI to summarize review items over a period of time.",
|
||||
)
|
||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if not config.genai.provider:
|
||||
if not request.app.genai_manager.vision_client:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
|
||||
@@ -19,6 +19,8 @@ class CameraMetrics:
|
||||
process_pid: Synchronized
|
||||
capture_process_pid: Synchronized
|
||||
ffmpeg_pid: Synchronized
|
||||
reconnects_last_hour: Synchronized
|
||||
stalls_last_hour: Synchronized
|
||||
|
||||
def __init__(self, manager: SyncManager):
|
||||
self.camera_fps = manager.Value("d", 0)
|
||||
@@ -35,6 +37,8 @@ class CameraMetrics:
|
||||
self.process_pid = manager.Value("i", 0)
|
||||
self.capture_process_pid = manager.Value("i", 0)
|
||||
self.ffmpeg_pid = manager.Value("i", 0)
|
||||
self.reconnects_last_hour = manager.Value("i", 0)
|
||||
self.stalls_last_hour = manager.Value("i", 0)
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
|
||||
@@ -65,7 +65,7 @@ class CameraState:
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
||||
# draw on the frame
|
||||
if draw_options.get("mask"):
|
||||
mask_overlay = np.where(self.camera_config.motion.mask == [0])
|
||||
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0])
|
||||
frame_copy[mask_overlay] = [0, 0, 0]
|
||||
|
||||
if draw_options.get("bounding_boxes"):
|
||||
@@ -197,6 +197,10 @@ class CameraState:
|
||||
|
||||
if draw_options.get("zones"):
|
||||
for name, zone in self.camera_config.zones.items():
|
||||
# skip disabled zones
|
||||
if not zone.enabled:
|
||||
continue
|
||||
|
||||
thickness = (
|
||||
8
|
||||
if any(
|
||||
|
||||
@@ -15,6 +15,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
|
||||
from frigate.const import (
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
@@ -28,6 +29,7 @@ from frigate.const import (
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_JOB_STATE,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
@@ -60,6 +62,7 @@ class Dispatcher:
|
||||
self.camera_activity = CameraActivityManager(config, self.publish)
|
||||
self.audio_activity = AudioActivityManager(config, self.publish)
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
self.job_state: dict[str, dict[str, Any]] = {} # {job_type: job_data}
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
@@ -82,6 +85,9 @@ class Dispatcher:
|
||||
"review_detections": self._on_detections_command,
|
||||
"object_descriptions": self._on_object_description_command,
|
||||
"review_descriptions": self._on_review_description_command,
|
||||
"motion_mask": self._on_motion_mask_command,
|
||||
"object_mask": self._on_object_mask_command,
|
||||
"zone": self._on_zone_command,
|
||||
}
|
||||
self._global_settings_handlers: dict[str, Callable] = {
|
||||
"notifications": self._on_global_notification_command,
|
||||
@@ -98,11 +104,20 @@ class Dispatcher:
|
||||
"""Handle receiving of payload from communicators."""
|
||||
|
||||
def handle_camera_command(
|
||||
command_type: str, camera_name: str, command: str, payload: str
|
||||
command_type: str,
|
||||
camera_name: str,
|
||||
command: str,
|
||||
payload: str,
|
||||
sub_command: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if command_type == "set":
|
||||
self._camera_settings_handlers[command](camera_name, payload)
|
||||
if sub_command:
|
||||
self._camera_settings_handlers[command](
|
||||
camera_name, sub_command, payload
|
||||
)
|
||||
else:
|
||||
self._camera_settings_handlers[command](camera_name, payload)
|
||||
elif command_type == "ptz":
|
||||
self._on_ptz_command(camera_name, payload)
|
||||
except KeyError:
|
||||
@@ -180,6 +195,19 @@ class Dispatcher:
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
def handle_update_job_state() -> None:
|
||||
if payload and isinstance(payload, dict):
|
||||
job_type = payload.get("job_type")
|
||||
if job_type:
|
||||
self.job_state[job_type] = payload
|
||||
self.publish(
|
||||
"job_state",
|
||||
json.dumps(self.job_state),
|
||||
)
|
||||
|
||||
def handle_job_state() -> None:
|
||||
self.publish("job_state", json.dumps(self.job_state.copy()))
|
||||
|
||||
def handle_update_audio_transcription_state() -> None:
|
||||
if payload:
|
||||
self.audio_transcription_state = payload
|
||||
@@ -277,6 +305,7 @@ class Dispatcher:
|
||||
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
||||
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_JOB_STATE: handle_update_job_state,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
@@ -284,6 +313,7 @@ class Dispatcher:
|
||||
"restart": handle_restart,
|
||||
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
||||
"modelState": handle_model_state,
|
||||
"jobState": handle_job_state,
|
||||
"audioTranscriptionState": handle_audio_transcription_state,
|
||||
"birdseyeLayout": handle_birdseye_layout,
|
||||
"onConnect": handle_on_connect,
|
||||
@@ -297,6 +327,14 @@ class Dispatcher:
|
||||
camera_name = parts[-3]
|
||||
command = parts[-2]
|
||||
handle_camera_command("set", camera_name, command, payload)
|
||||
elif len(parts) == 4 and topic.endswith("set"):
|
||||
# example /cam_name/motion_mask/mask_name/set payload=ON|OFF
|
||||
camera_name = parts[-4]
|
||||
command = parts[-3]
|
||||
sub_command = parts[-2]
|
||||
handle_camera_command(
|
||||
"set", camera_name, command, payload, sub_command
|
||||
)
|
||||
elif len(parts) == 2 and topic.endswith("set"):
|
||||
command = parts[-2]
|
||||
self._global_settings_handlers[command](payload)
|
||||
@@ -841,3 +879,149 @@ class Dispatcher:
|
||||
genai_settings,
|
||||
)
|
||||
self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True)
|
||||
|
||||
def _on_motion_mask_command(
|
||||
self, camera_name: str, mask_name: str, payload: str
|
||||
) -> None:
|
||||
"""Callback for motion mask topic."""
|
||||
if payload not in ["ON", "OFF"]:
|
||||
logger.error(f"Invalid payload for motion mask {mask_name}: {payload}")
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
|
||||
if mask_name not in motion_settings.mask:
|
||||
logger.error(f"Unknown motion mask: {mask_name}")
|
||||
return
|
||||
|
||||
mask = motion_settings.mask[mask_name]
|
||||
|
||||
if not mask:
|
||||
logger.error(f"Motion mask {mask_name} is None")
|
||||
return
|
||||
|
||||
if payload == "ON":
|
||||
if not mask.enabled_in_config:
|
||||
logger.error(
|
||||
f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
|
||||
mask.enabled = payload == "ON"
|
||||
|
||||
# Recreate RuntimeMotionConfig to update rasterized_mask
|
||||
motion_settings = RuntimeMotionConfig(
|
||||
frame_shape=self.config.cameras[camera_name].frame_shape,
|
||||
**motion_settings.model_dump(exclude_unset=True),
|
||||
)
|
||||
|
||||
# Update the dispatcher's own config
|
||||
self.config.cameras[camera_name].motion = motion_settings
|
||||
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
|
||||
motion_settings,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True
|
||||
)
|
||||
|
||||
def _on_object_mask_command(
|
||||
self, camera_name: str, mask_name: str, payload: str
|
||||
) -> None:
|
||||
"""Callback for object mask topic."""
|
||||
if payload not in ["ON", "OFF"]:
|
||||
logger.error(f"Invalid payload for object mask {mask_name}: {payload}")
|
||||
return
|
||||
|
||||
object_settings = self.config.cameras[camera_name].objects
|
||||
|
||||
# Check if this is a global mask
|
||||
mask_found = False
|
||||
if mask_name in object_settings.mask:
|
||||
mask = object_settings.mask[mask_name]
|
||||
if mask:
|
||||
if payload == "ON":
|
||||
if not mask.enabled_in_config:
|
||||
logger.error(
|
||||
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
mask.enabled = payload == "ON"
|
||||
mask_found = True
|
||||
|
||||
# Check if this is a per-object filter mask
|
||||
for object_name, filter_config in object_settings.filters.items():
|
||||
if mask_name in filter_config.mask:
|
||||
mask = filter_config.mask[mask_name]
|
||||
if mask:
|
||||
if payload == "ON":
|
||||
if not mask.enabled_in_config:
|
||||
logger.error(
|
||||
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
mask.enabled = payload == "ON"
|
||||
mask_found = True
|
||||
|
||||
if not mask_found:
|
||||
logger.error(f"Unknown object mask: {mask_name}")
|
||||
return
|
||||
|
||||
# Recreate RuntimeFilterConfig for each object filter to update rasterized_mask
|
||||
for object_name, filter_config in object_settings.filters.items():
|
||||
# Merge global object masks with per-object filter masks
|
||||
merged_mask = dict(filter_config.mask) # Copy filter-specific masks
|
||||
|
||||
# Add global object masks if they exist
|
||||
if object_settings.mask:
|
||||
for global_mask_id, global_mask_config in object_settings.mask.items():
|
||||
# Use a global prefix to avoid key collisions
|
||||
global_mask_id_prefixed = f"global_{global_mask_id}"
|
||||
merged_mask[global_mask_id_prefixed] = global_mask_config
|
||||
|
||||
object_settings.filters[object_name] = RuntimeFilterConfig(
|
||||
frame_shape=self.config.cameras[camera_name].frame_shape,
|
||||
mask=merged_mask,
|
||||
**filter_config.model_dump(
|
||||
exclude_unset=True, exclude={"mask", "raw_mask"}
|
||||
),
|
||||
)
|
||||
|
||||
# Update the dispatcher's own config
|
||||
self.config.cameras[camera_name].objects = object_settings
|
||||
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name),
|
||||
object_settings,
|
||||
)
|
||||
self.publish(
|
||||
f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True
|
||||
)
|
||||
|
||||
def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None:
|
||||
"""Callback for zone topic."""
|
||||
if payload not in ["ON", "OFF"]:
|
||||
logger.error(f"Invalid payload for zone {zone_name}: {payload}")
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras[camera_name]
|
||||
|
||||
if zone_name not in camera_config.zones:
|
||||
logger.error(f"Unknown zone: {zone_name}")
|
||||
return
|
||||
|
||||
if payload == "ON":
|
||||
if not camera_config.zones[zone_name].enabled_in_config:
|
||||
logger.error(
|
||||
f"Zone {zone_name} must be enabled in the config to be turned on via MQTT."
|
||||
)
|
||||
return
|
||||
|
||||
camera_config.zones[zone_name].enabled = payload == "ON"
|
||||
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name),
|
||||
camera_config.zones,
|
||||
)
|
||||
self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True)
|
||||
|
||||
@@ -133,6 +133,29 @@ class MqttClient(Communicator):
|
||||
retain=True,
|
||||
)
|
||||
|
||||
for mask_name, motion_mask in camera.motion.mask.items():
|
||||
if motion_mask:
|
||||
self.publish(
|
||||
f"{camera_name}/motion_mask/{mask_name}/state",
|
||||
"ON" if motion_mask.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
for mask_name, object_mask in camera.objects.mask.items():
|
||||
if object_mask:
|
||||
self.publish(
|
||||
f"{camera_name}/object_mask/{mask_name}/state",
|
||||
"ON" if object_mask.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
for zone_name, zone in camera.zones.items():
|
||||
self.publish(
|
||||
f"{camera_name}/zone/{zone_name}/state",
|
||||
"ON" if zone.enabled else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.publish(
|
||||
"notifications/state",
|
||||
@@ -242,6 +265,24 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
for mask_name in self.config.cameras[name].motion.mask.keys():
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
for mask_name in self.config.cameras[name].objects.mask.keys():
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
for zone_name in self.config.cameras[name].zones.keys():
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/notifications/set",
|
||||
|
||||
@@ -8,6 +8,7 @@ from .config import * # noqa: F403
|
||||
from .database import * # noqa: F403
|
||||
from .logger import * # noqa: F403
|
||||
from .mqtt import * # noqa: F403
|
||||
from .network import * # noqa: F403
|
||||
from .proxy import * # noqa: F403
|
||||
from .telemetry import * # noqa: F403
|
||||
from .tls import * # noqa: F403
|
||||
|
||||
@@ -8,39 +8,63 @@ __all__ = ["AuthConfig"]
|
||||
|
||||
|
||||
class AuthConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable authentication")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable authentication",
|
||||
description="Enable native authentication for the Frigate UI.",
|
||||
)
|
||||
reset_admin_password: bool = Field(
|
||||
default=False, title="Reset the admin password on startup"
|
||||
default=False,
|
||||
title="Reset admin password",
|
||||
description="If true, reset the admin user's password on startup and print the new password in logs.",
|
||||
)
|
||||
cookie_name: str = Field(
|
||||
default="frigate_token", title="Name for jwt token cookie", pattern=r"^[a-z_]+$"
|
||||
default="frigate_token",
|
||||
title="JWT cookie name",
|
||||
description="Name of the cookie used to store the JWT token for native authentication.",
|
||||
pattern=r"^[a-z_]+$",
|
||||
)
|
||||
cookie_secure: bool = Field(
|
||||
default=False,
|
||||
title="Secure cookie flag",
|
||||
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
||||
)
|
||||
cookie_secure: bool = Field(default=False, title="Set secure flag on cookie")
|
||||
session_length: int = Field(
|
||||
default=86400, title="Session length for jwt session tokens", ge=60
|
||||
default=86400,
|
||||
title="Session length",
|
||||
description="Session duration in seconds for JWT-based sessions.",
|
||||
ge=60,
|
||||
)
|
||||
refresh_time: int = Field(
|
||||
default=1800,
|
||||
title="Refresh the session if it is going to expire in this many seconds",
|
||||
title="Session refresh window",
|
||||
description="When a session is within this many seconds of expiring, refresh it back to full length.",
|
||||
ge=30,
|
||||
)
|
||||
failed_login_rate_limit: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Rate limits for failed login attempts.",
|
||||
title="Failed login limits",
|
||||
description="Rate limiting rules for failed login attempts to reduce brute-force attacks.",
|
||||
)
|
||||
trusted_proxies: list[str] = Field(
|
||||
default=[],
|
||||
title="Trusted proxies for determining IP address to rate limit",
|
||||
title="Trusted proxies",
|
||||
description="List of trusted proxy IPs used when determining client IP for rate limiting.",
|
||||
)
|
||||
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
|
||||
hash_iterations: int = Field(default=600000, title="Password hash iterations")
|
||||
hash_iterations: int = Field(
|
||||
default=600000,
|
||||
title="Hash iterations",
|
||||
description="Number of PBKDF2-SHA256 iterations to use when hashing user passwords.",
|
||||
)
|
||||
roles: Dict[str, List[str]] = Field(
|
||||
default_factory=dict,
|
||||
title="Role to camera mappings. Empty list grants access to all cameras.",
|
||||
title="Role mappings",
|
||||
description="Map roles to camera lists. An empty list grants access to all cameras for the role.",
|
||||
)
|
||||
admin_first_time_login: Optional[bool] = Field(
|
||||
default=False,
|
||||
title="Internal field to expose first-time admin login flag to the UI",
|
||||
title="First-time admin flag",
|
||||
description=(
|
||||
"When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. "
|
||||
),
|
||||
|
||||
@@ -17,25 +17,45 @@ class AudioFilterConfig(FrigateBaseModel):
|
||||
default=0.8,
|
||||
ge=AUDIO_MIN_CONFIDENCE,
|
||||
lt=1.0,
|
||||
title="Minimum detection confidence threshold for audio to be counted.",
|
||||
title="Minimum audio confidence",
|
||||
description="Minimum confidence threshold for the audio event to be counted.",
|
||||
)
|
||||
|
||||
|
||||
class AudioConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable audio events.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable audio detection",
|
||||
description="Enable or disable audio event detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
max_not_heard: int = Field(
|
||||
default=30, title="Seconds of not hearing the type of audio to end the event."
|
||||
default=30,
|
||||
title="End timeout",
|
||||
description="Amount of seconds without the configured audio type before the audio event is ended.",
|
||||
)
|
||||
min_volume: int = Field(
|
||||
default=500, title="Min volume required to run audio detection."
|
||||
default=500,
|
||||
title="Minimum volume",
|
||||
description="Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).",
|
||||
)
|
||||
listen: list[str] = Field(
|
||||
default=DEFAULT_LISTEN_AUDIO, title="Audio to listen for."
|
||||
default=DEFAULT_LISTEN_AUDIO,
|
||||
title="Listen types",
|
||||
description="List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell).",
|
||||
)
|
||||
filters: Optional[dict[str, AudioFilterConfig]] = Field(
|
||||
None, title="Audio filters."
|
||||
None,
|
||||
title="Audio filters",
|
||||
description="Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
None, title="Keep track of original state of audio detection."
|
||||
None,
|
||||
title="Original audio state",
|
||||
description="Indicates whether audio detection was originally enabled in the static config file.",
|
||||
)
|
||||
num_threads: int = Field(
|
||||
default=2,
|
||||
title="Detection threads",
|
||||
description="Number of threads to use for audio detection processing.",
|
||||
ge=1,
|
||||
)
|
||||
num_threads: int = Field(default=2, title="Number of detection threads", ge=1)
|
||||
|
||||
@@ -29,45 +29,88 @@ class BirdseyeModeEnum(str, Enum):
|
||||
|
||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||
scaling_factor: float = Field(
|
||||
default=2.0, title="Birdseye Scaling Factor", ge=1.0, le=5.0
|
||||
default=2.0,
|
||||
title="Scaling factor",
|
||||
description="Scaling factor used by the layout calculator (range 1.0 to 5.0).",
|
||||
ge=1.0,
|
||||
le=5.0,
|
||||
)
|
||||
max_cameras: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Max cameras",
|
||||
description="Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.",
|
||||
)
|
||||
max_cameras: Optional[int] = Field(default=None, title="Max cameras")
|
||||
|
||||
|
||||
class BirdseyeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable birdseye view.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable Birdseye",
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
mode: BirdseyeModeEnum = Field(
|
||||
default=BirdseyeModeEnum.objects, title="Tracking mode."
|
||||
default=BirdseyeModeEnum.objects,
|
||||
title="Tracking mode",
|
||||
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
|
||||
)
|
||||
|
||||
restream: bool = Field(default=False, title="Restream birdseye via RTSP.")
|
||||
width: int = Field(default=1280, title="Birdseye width.")
|
||||
height: int = Field(default=720, title="Birdseye height.")
|
||||
restream: bool = Field(
|
||||
default=False,
|
||||
title="Restream RTSP",
|
||||
description="Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.",
|
||||
)
|
||||
width: int = Field(
|
||||
default=1280,
|
||||
title="Width",
|
||||
description="Output width (pixels) of the composed Birdseye frame.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Height",
|
||||
description="Output height (pixels) of the composed Birdseye frame.",
|
||||
)
|
||||
quality: int = Field(
|
||||
default=8,
|
||||
title="Encoding quality.",
|
||||
title="Encoding quality",
|
||||
description="Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).",
|
||||
ge=1,
|
||||
le=31,
|
||||
)
|
||||
inactivity_threshold: int = Field(
|
||||
default=30, title="Birdseye Inactivity Threshold", gt=0
|
||||
default=30,
|
||||
title="Inactivity threshold",
|
||||
description="Seconds of inactivity after which a camera will stop being shown in Birdseye.",
|
||||
gt=0,
|
||||
)
|
||||
layout: BirdseyeLayoutConfig = Field(
|
||||
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
|
||||
default_factory=BirdseyeLayoutConfig,
|
||||
title="Layout",
|
||||
description="Layout options for the Birdseye composition.",
|
||||
)
|
||||
idle_heartbeat_fps: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=10.0,
|
||||
title="Idle heartbeat FPS (0 disables, max 10)",
|
||||
title="Idle heartbeat FPS",
|
||||
description="Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.",
|
||||
)
|
||||
|
||||
|
||||
# uses BaseModel because some global attributes are not available at the camera level
|
||||
class BirdseyeCameraConfig(BaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable birdseye view for camera.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable Birdseye",
|
||||
description="Enable or disable the Birdseye view feature.",
|
||||
)
|
||||
mode: BirdseyeModeEnum = Field(
|
||||
default=BirdseyeModeEnum.objects, title="Tracking mode for camera."
|
||||
default=BirdseyeModeEnum.objects,
|
||||
title="Tracking mode",
|
||||
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
|
||||
)
|
||||
|
||||
order: int = Field(default=0, title="Position of the camera in the birdseye view.")
|
||||
order: int = Field(
|
||||
default=0,
|
||||
title="Position",
|
||||
description="Numeric position controlling the camera's ordering in the Birdseye layout.",
|
||||
)
|
||||
|
||||
@@ -50,10 +50,17 @@ class CameraTypeEnum(str, Enum):
|
||||
|
||||
|
||||
class CameraConfig(FrigateBaseModel):
|
||||
name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME)
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
title="Camera name",
|
||||
description="Camera name is required",
|
||||
pattern=REGEX_CAMERA_NAME,
|
||||
)
|
||||
|
||||
friendly_name: Optional[str] = Field(
|
||||
None, title="Camera friendly name used in the Frigate UI."
|
||||
None,
|
||||
title="Friendly name",
|
||||
description="Camera friendly name used in the Frigate UI",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -63,80 +70,129 @@ class CameraConfig(FrigateBaseModel):
|
||||
pass
|
||||
return values
|
||||
|
||||
enabled: bool = Field(default=True, title="Enable camera.")
|
||||
enabled: bool = Field(default=True, title="Enabled", description="Enabled")
|
||||
|
||||
# Options with global fallback
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig, title="Audio events configuration."
|
||||
default_factory=AudioConfig,
|
||||
title="Audio events",
|
||||
description="Settings for audio-based event detection for this camera.",
|
||||
)
|
||||
audio_transcription: CameraAudioTranscriptionConfig = Field(
|
||||
default_factory=CameraAudioTranscriptionConfig,
|
||||
title="Audio transcription config.",
|
||||
title="Audio transcription",
|
||||
description="Settings for live and speech audio transcription used for events and live captions.",
|
||||
)
|
||||
birdseye: BirdseyeCameraConfig = Field(
|
||||
default_factory=BirdseyeCameraConfig, title="Birdseye camera configuration."
|
||||
default_factory=BirdseyeCameraConfig,
|
||||
title="Birdseye",
|
||||
description="Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.",
|
||||
)
|
||||
detect: DetectConfig = Field(
|
||||
default_factory=DetectConfig, title="Object detection configuration."
|
||||
default_factory=DetectConfig,
|
||||
title="Object Detection",
|
||||
description="Settings for the detection/detect role used to run object detection and initialize trackers.",
|
||||
)
|
||||
face_recognition: CameraFaceRecognitionConfig = Field(
|
||||
default_factory=CameraFaceRecognitionConfig, title="Face recognition config."
|
||||
default_factory=CameraFaceRecognitionConfig,
|
||||
title="Face recognition",
|
||||
description="Settings for face detection and recognition for this camera.",
|
||||
)
|
||||
ffmpeg: CameraFfmpegConfig = Field(
|
||||
title="FFmpeg",
|
||||
description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
|
||||
)
|
||||
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
|
||||
live: CameraLiveConfig = Field(
|
||||
default_factory=CameraLiveConfig, title="Live playback settings."
|
||||
default_factory=CameraLiveConfig,
|
||||
title="Live playback",
|
||||
description="Settings used by the Web UI to control live stream selection, resolution and quality.",
|
||||
)
|
||||
lpr: CameraLicensePlateRecognitionConfig = Field(
|
||||
default_factory=CameraLicensePlateRecognitionConfig, title="LPR config."
|
||||
default_factory=CameraLicensePlateRecognitionConfig,
|
||||
title="License Plate Recognition",
|
||||
description="License plate recognition settings including detection thresholds, formatting, and known plates.",
|
||||
)
|
||||
motion: MotionConfig = Field(
|
||||
None,
|
||||
title="Motion detection",
|
||||
description="Default motion detection settings for this camera.",
|
||||
)
|
||||
motion: MotionConfig = Field(None, title="Motion detection configuration.")
|
||||
objects: ObjectConfig = Field(
|
||||
default_factory=ObjectConfig, title="Object configuration."
|
||||
default_factory=ObjectConfig,
|
||||
title="Objects",
|
||||
description="Object tracking defaults including which labels to track and per-object filters.",
|
||||
)
|
||||
record: RecordConfig = Field(
|
||||
default_factory=RecordConfig, title="Record configuration."
|
||||
default_factory=RecordConfig,
|
||||
title="Recording",
|
||||
description="Recording and retention settings for this camera.",
|
||||
)
|
||||
review: ReviewConfig = Field(
|
||||
default_factory=ReviewConfig, title="Review configuration."
|
||||
default_factory=ReviewConfig,
|
||||
title="Review",
|
||||
description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
|
||||
)
|
||||
semantic_search: CameraSemanticSearchConfig = Field(
|
||||
default_factory=CameraSemanticSearchConfig,
|
||||
title="Semantic search configuration.",
|
||||
title="Semantic Search",
|
||||
description="Settings for semantic search which builds and queries object embeddings to find similar items.",
|
||||
)
|
||||
snapshots: SnapshotsConfig = Field(
|
||||
default_factory=SnapshotsConfig, title="Snapshot configuration."
|
||||
default_factory=SnapshotsConfig,
|
||||
title="Snapshots",
|
||||
description="Settings for saved JPEG snapshots of tracked objects for this camera.",
|
||||
)
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig, title="Timestamp style configuration."
|
||||
default_factory=TimestampStyleConfig,
|
||||
title="Timestamp style",
|
||||
description="Styling options for in-feed timestamps applied to recordings and snapshots.",
|
||||
)
|
||||
|
||||
# Options without global fallback
|
||||
best_image_timeout: int = Field(
|
||||
default=60,
|
||||
title="How long to wait for the image with the highest confidence score.",
|
||||
title="Best image timeout",
|
||||
description="How long to wait for the image with the highest confidence score.",
|
||||
)
|
||||
mqtt: CameraMqttConfig = Field(
|
||||
default_factory=CameraMqttConfig, title="MQTT configuration."
|
||||
default_factory=CameraMqttConfig,
|
||||
title="MQTT",
|
||||
description="MQTT image publishing settings.",
|
||||
)
|
||||
notifications: NotificationConfig = Field(
|
||||
default_factory=NotificationConfig, title="Notifications configuration."
|
||||
default_factory=NotificationConfig,
|
||||
title="Notifications",
|
||||
description="Settings to enable and control notifications for this camera.",
|
||||
)
|
||||
onvif: OnvifConfig = Field(
|
||||
default_factory=OnvifConfig, title="Camera Onvif Configuration."
|
||||
default_factory=OnvifConfig,
|
||||
title="ONVIF",
|
||||
description="ONVIF connection and PTZ autotracking settings for this camera.",
|
||||
)
|
||||
type: CameraTypeEnum = Field(
|
||||
default=CameraTypeEnum.generic,
|
||||
title="Camera type",
|
||||
description="Camera Type",
|
||||
)
|
||||
type: CameraTypeEnum = Field(default=CameraTypeEnum.generic, title="Camera Type")
|
||||
ui: CameraUiConfig = Field(
|
||||
default_factory=CameraUiConfig, title="Camera UI Modifications."
|
||||
default_factory=CameraUiConfig,
|
||||
title="Camera UI",
|
||||
description="Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.",
|
||||
)
|
||||
webui_url: Optional[str] = Field(
|
||||
None,
|
||||
title="URL to visit the camera directly from system page",
|
||||
title="Camera URL",
|
||||
description="URL to visit the camera directly from system page",
|
||||
)
|
||||
zones: dict[str, ZoneConfig] = Field(
|
||||
default_factory=dict, title="Zone configuration."
|
||||
default_factory=dict,
|
||||
title="Zones",
|
||||
description="Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of camera."
|
||||
default=None,
|
||||
title="Original camera state",
|
||||
description="Keep track of original state of camera.",
|
||||
)
|
||||
|
||||
_ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()
|
||||
|
||||
@@ -8,56 +8,82 @@ __all__ = ["DetectConfig", "StationaryConfig", "StationaryMaxFramesConfig"]
|
||||
|
||||
|
||||
class StationaryMaxFramesConfig(FrigateBaseModel):
|
||||
default: Optional[int] = Field(default=None, title="Default max frames.", ge=1)
|
||||
default: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Default max frames",
|
||||
description="Default maximum frames to track a stationary object before stopping.",
|
||||
ge=1,
|
||||
)
|
||||
objects: dict[str, int] = Field(
|
||||
default_factory=dict, title="Object specific max frames."
|
||||
default_factory=dict,
|
||||
title="Object max frames",
|
||||
description="Per-object overrides for maximum frames to track stationary objects.",
|
||||
)
|
||||
|
||||
|
||||
class StationaryConfig(FrigateBaseModel):
|
||||
interval: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Frame interval for checking stationary objects.",
|
||||
title="Stationary interval",
|
||||
description="How often (in frames) to run a detection check to confirm a stationary object.",
|
||||
gt=0,
|
||||
)
|
||||
threshold: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Number of frames without a position change for an object to be considered stationary",
|
||||
title="Stationary threshold",
|
||||
description="Number of frames with no position change required to mark an object as stationary.",
|
||||
ge=1,
|
||||
)
|
||||
max_frames: StationaryMaxFramesConfig = Field(
|
||||
default_factory=StationaryMaxFramesConfig,
|
||||
title="Max frames for stationary objects.",
|
||||
title="Max frames",
|
||||
description="Limits how long stationary objects are tracked before being discarded.",
|
||||
)
|
||||
classifier: bool = Field(
|
||||
default=True,
|
||||
title="Enable visual classifier for determing if objects with jittery bounding boxes are stationary.",
|
||||
title="Enable visual classifier",
|
||||
description="Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.",
|
||||
)
|
||||
|
||||
|
||||
class DetectConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Detection Enabled.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Detection enabled",
|
||||
description="Enable or disable object detection for all cameras; can be overridden per-camera. Detection must be enabled for object tracking to run.",
|
||||
)
|
||||
height: Optional[int] = Field(
|
||||
default=None, title="Height of the stream for the detect role."
|
||||
default=None,
|
||||
title="Detect height",
|
||||
description="Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
width: Optional[int] = Field(
|
||||
default=None, title="Width of the stream for the detect role."
|
||||
default=None,
|
||||
title="Detect width",
|
||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
fps: int = Field(
|
||||
default=5, title="Number of frames per second to process through detection."
|
||||
default=5,
|
||||
title="Detect FPS",
|
||||
description="Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).",
|
||||
)
|
||||
min_initialized: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Minimum number of consecutive hits for an object to be initialized by the tracker.",
|
||||
title="Minimum initialization frames",
|
||||
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
|
||||
)
|
||||
max_disappeared: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Maximum number of frames the object can disappear before detection ends.",
|
||||
title="Maximum disappeared frames",
|
||||
description="Number of frames without a detection before a tracked object is considered gone.",
|
||||
)
|
||||
stationary: StationaryConfig = Field(
|
||||
default_factory=StationaryConfig,
|
||||
title="Stationary objects config.",
|
||||
title="Stationary objects config",
|
||||
description="Settings to detect and manage objects that remain stationary for a period of time.",
|
||||
)
|
||||
annotation_offset: int = Field(
|
||||
default=0, title="Milliseconds to offset detect annotations by."
|
||||
default=0,
|
||||
title="Annotation offset",
|
||||
description="Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.",
|
||||
)
|
||||
|
||||
@@ -35,39 +35,58 @@ DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [
|
||||
class FfmpegOutputArgsConfig(FrigateBaseModel):
|
||||
detect: Union[str, list[str]] = Field(
|
||||
default=DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT,
|
||||
title="Detect role FFmpeg output arguments.",
|
||||
title="Detect output arguments",
|
||||
description="Default output arguments for detect role streams.",
|
||||
)
|
||||
record: Union[str, list[str]] = Field(
|
||||
default=RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT,
|
||||
title="Record role FFmpeg output arguments.",
|
||||
title="Record output arguments",
|
||||
description="Default output arguments for record role streams.",
|
||||
)
|
||||
|
||||
|
||||
class FfmpegConfig(FrigateBaseModel):
|
||||
path: str = Field(default="default", title="FFmpeg path")
|
||||
path: str = Field(
|
||||
default="default",
|
||||
title="FFmpeg path",
|
||||
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
|
||||
)
|
||||
global_args: Union[str, list[str]] = Field(
|
||||
default=FFMPEG_GLOBAL_ARGS_DEFAULT, title="Global FFmpeg arguments."
|
||||
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
|
||||
title="FFmpeg global arguments",
|
||||
description="Global arguments passed to FFmpeg processes.",
|
||||
)
|
||||
hwaccel_args: Union[str, list[str]] = Field(
|
||||
default="auto", title="FFmpeg hardware acceleration arguments."
|
||||
default="auto",
|
||||
title="Hardware acceleration arguments",
|
||||
description="Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.",
|
||||
)
|
||||
input_args: Union[str, list[str]] = Field(
|
||||
default=FFMPEG_INPUT_ARGS_DEFAULT, title="FFmpeg input arguments."
|
||||
default=FFMPEG_INPUT_ARGS_DEFAULT,
|
||||
title="Input arguments",
|
||||
description="Input arguments applied to FFmpeg input streams.",
|
||||
)
|
||||
output_args: FfmpegOutputArgsConfig = Field(
|
||||
default_factory=FfmpegOutputArgsConfig,
|
||||
title="FFmpeg output arguments per role.",
|
||||
title="Output arguments",
|
||||
description="Default output arguments used for different FFmpeg roles such as detect and record.",
|
||||
)
|
||||
retry_interval: float = Field(
|
||||
default=10.0,
|
||||
title="Time in seconds to wait before FFmpeg retries connecting to the camera.",
|
||||
title="FFmpeg retry time",
|
||||
description="Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.",
|
||||
gt=0.0,
|
||||
)
|
||||
apple_compatibility: bool = Field(
|
||||
default=False,
|
||||
title="Set tag on HEVC (H.265) recording stream to improve compatibility with Apple players.",
|
||||
title="Apple compatibility",
|
||||
description="Enable HEVC tagging for better Apple player compatibility when recording H.265.",
|
||||
)
|
||||
gpu: int = Field(
|
||||
default=0,
|
||||
title="GPU index",
|
||||
description="Default GPU index used for hardware acceleration if available.",
|
||||
)
|
||||
gpu: int = Field(default=0, title="GPU index to use for hardware acceleration.")
|
||||
|
||||
@property
|
||||
def ffmpeg_path(self) -> str:
|
||||
@@ -95,21 +114,36 @@ class CameraRoleEnum(str, Enum):
|
||||
|
||||
|
||||
class CameraInput(FrigateBaseModel):
|
||||
path: EnvString = Field(title="Camera input path.")
|
||||
roles: list[CameraRoleEnum] = Field(title="Roles assigned to this input.")
|
||||
path: EnvString = Field(
|
||||
title="Input path",
|
||||
description="Camera input stream URL or path.",
|
||||
)
|
||||
roles: list[CameraRoleEnum] = Field(
|
||||
title="Input roles",
|
||||
description="Roles for this input stream.",
|
||||
)
|
||||
global_args: Union[str, list[str]] = Field(
|
||||
default_factory=list, title="FFmpeg global arguments."
|
||||
default_factory=list,
|
||||
title="FFmpeg global arguments",
|
||||
description="FFmpeg global arguments for this input stream.",
|
||||
)
|
||||
hwaccel_args: Union[str, list[str]] = Field(
|
||||
default_factory=list, title="FFmpeg hardware acceleration arguments."
|
||||
default_factory=list,
|
||||
title="Hardware acceleration arguments",
|
||||
description="Hardware acceleration arguments for this input stream.",
|
||||
)
|
||||
input_args: Union[str, list[str]] = Field(
|
||||
default_factory=list, title="FFmpeg input arguments."
|
||||
default_factory=list,
|
||||
title="Input arguments",
|
||||
description="Input arguments specific to this stream.",
|
||||
)
|
||||
|
||||
|
||||
class CameraFfmpegConfig(FfmpegConfig):
|
||||
inputs: list[CameraInput] = Field(title="Camera inputs.")
|
||||
inputs: list[CameraInput] = Field(
|
||||
title="Camera inputs",
|
||||
description="List of input stream definitions (paths and roles) for this camera.",
|
||||
)
|
||||
|
||||
@field_validator("inputs")
|
||||
@classmethod
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import Field
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["GenAIConfig", "GenAIProviderEnum"]
|
||||
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
|
||||
|
||||
|
||||
class GenAIProviderEnum(str, Enum):
|
||||
@@ -14,15 +14,56 @@ class GenAIProviderEnum(str, Enum):
|
||||
azure_openai = "azure_openai"
|
||||
gemini = "gemini"
|
||||
ollama = "ollama"
|
||||
llamacpp = "llamacpp"
|
||||
|
||||
|
||||
class GenAIRoleEnum(str, Enum):
|
||||
tools = "tools"
|
||||
vision = "vision"
|
||||
embeddings = "embeddings"
|
||||
|
||||
|
||||
class GenAIConfig(FrigateBaseModel):
|
||||
"""Primary GenAI Config to define GenAI Provider."""
|
||||
|
||||
api_key: Optional[EnvString] = Field(default=None, title="Provider API key.")
|
||||
base_url: Optional[str] = Field(default=None, title="Provider base url.")
|
||||
model: str = Field(default="gpt-4o", title="GenAI model.")
|
||||
provider: GenAIProviderEnum | None = Field(default=None, title="GenAI provider.")
|
||||
provider_options: dict[str, Any] = Field(
|
||||
default={}, title="GenAI Provider extra options."
|
||||
api_key: Optional[EnvString] = Field(
|
||||
default=None,
|
||||
title="API key",
|
||||
description="API key required by some providers (can also be set via environment variables).",
|
||||
)
|
||||
base_url: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Base URL",
|
||||
description="Base URL for self-hosted or compatible providers (for example an Ollama instance).",
|
||||
)
|
||||
model: str = Field(
|
||||
default="gpt-4o",
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
provider: GenAIProviderEnum | None = Field(
|
||||
default=None,
|
||||
title="Provider",
|
||||
description="The GenAI provider to use (for example: ollama, gemini, openai).",
|
||||
)
|
||||
roles: list[GenAIRoleEnum] = Field(
|
||||
default_factory=lambda: [
|
||||
GenAIRoleEnum.embeddings,
|
||||
GenAIRoleEnum.vision,
|
||||
GenAIRoleEnum.tools,
|
||||
],
|
||||
title="Roles",
|
||||
description="GenAI roles (tools, vision, embeddings); one provider per role.",
|
||||
)
|
||||
provider_options: dict[str, Any] = Field(
|
||||
default={},
|
||||
title="Provider options",
|
||||
description="Additional provider-specific options to pass to the GenAI client.",
|
||||
json_schema_extra={"additionalProperties": {"type": "string"}},
|
||||
)
|
||||
runtime_options: dict[str, Any] = Field(
|
||||
default={},
|
||||
title="Runtime options",
|
||||
description="Runtime options passed to the provider for each inference call.",
|
||||
json_schema_extra={"additionalProperties": {"type": "string"}},
|
||||
)
|
||||
|
||||
@@ -10,7 +10,18 @@ __all__ = ["CameraLiveConfig"]
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
streams: Dict[str, str] = Field(
|
||||
default_factory=list,
|
||||
title="Friendly names and restream names to use for live view.",
|
||||
title="Live stream names",
|
||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Live height",
|
||||
description="Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.",
|
||||
)
|
||||
quality: int = Field(
|
||||
default=8,
|
||||
ge=1,
|
||||
le=31,
|
||||
title="Live quality",
|
||||
description="Encoding quality for the jsmpeg stream (1 highest, 31 lowest).",
|
||||
)
|
||||
height: int = Field(default=720, title="Live camera view height")
|
||||
quality: int = Field(default=8, ge=1, le=31, title="Live camera view quality")
|
||||
|
||||
85
frigate/config/camera/mask.py
Normal file
85
frigate/config/camera/mask.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Mask configuration for motion and object masks."""
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from pydantic import Field, field_serializer
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["MotionMaskConfig", "ObjectMaskConfig"]
|
||||
|
||||
|
||||
class MotionMaskConfig(FrigateBaseModel):
|
||||
"""Configuration for a single motion mask."""
|
||||
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Friendly name",
|
||||
description="A friendly name for this motion mask used in the Frigate UI",
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enabled",
|
||||
description="Enable or disable this motion mask",
|
||||
)
|
||||
coordinates: Union[str, list[str]] = Field(
|
||||
default="",
|
||||
title="Coordinates",
|
||||
description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.",
|
||||
)
|
||||
raw_coordinates: Union[str, list[str]] = ""
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of motion mask."
|
||||
)
|
||||
|
||||
def get_formatted_name(self, mask_id: str) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the mask ID."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return mask_id.replace("_", " ").title()
|
||||
|
||||
@field_serializer("coordinates", when_used="json")
|
||||
def serialize_coordinates(self, value: Any, info):
|
||||
return self.raw_coordinates if self.raw_coordinates else value
|
||||
|
||||
@field_serializer("raw_coordinates", when_used="json")
|
||||
def serialize_raw_coordinates(self, value: Any, info):
|
||||
return None
|
||||
|
||||
|
||||
class ObjectMaskConfig(FrigateBaseModel):
|
||||
"""Configuration for a single object mask."""
|
||||
|
||||
friendly_name: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Friendly name",
|
||||
description="A friendly name for this object mask used in the Frigate UI",
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enabled",
|
||||
description="Enable or disable this object mask",
|
||||
)
|
||||
coordinates: Union[str, list[str]] = Field(
|
||||
default="",
|
||||
title="Coordinates",
|
||||
description="Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.",
|
||||
)
|
||||
raw_coordinates: Union[str, list[str]] = ""
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of object mask."
|
||||
)
|
||||
|
||||
@field_serializer("coordinates", when_used="json")
|
||||
def serialize_coordinates(self, value: Any, info):
|
||||
return self.raw_coordinates if self.raw_coordinates else value
|
||||
|
||||
@field_serializer("raw_coordinates", when_used="json")
|
||||
def serialize_raw_coordinates(self, value: Any, info):
|
||||
return None
|
||||
|
||||
def get_formatted_name(self, mask_id: str) -> str:
|
||||
"""Return the friendly name if set, otherwise return a formatted version of the mask ID."""
|
||||
if self.friendly_name:
|
||||
return self.friendly_name
|
||||
return mask_id.replace("_", " ").title()
|
||||
@@ -1,43 +1,82 @@
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import Field, field_serializer
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from .mask import MotionMaskConfig
|
||||
|
||||
__all__ = ["MotionConfig"]
|
||||
|
||||
|
||||
class MotionConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable motion on all cameras.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable motion detection",
|
||||
description="Enable or disable motion detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
threshold: int = Field(
|
||||
default=30,
|
||||
title="Motion detection threshold (1-255).",
|
||||
title="Motion threshold",
|
||||
description="Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).",
|
||||
ge=1,
|
||||
le=255,
|
||||
)
|
||||
lightning_threshold: float = Field(
|
||||
default=0.8, title="Lightning detection threshold (0.3-1.0).", ge=0.3, le=1.0
|
||||
default=0.8,
|
||||
title="Lightning threshold",
|
||||
description="Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0).",
|
||||
ge=0.3,
|
||||
le=1.0,
|
||||
)
|
||||
improve_contrast: bool = Field(default=True, title="Improve Contrast")
|
||||
contour_area: Optional[int] = Field(default=10, title="Contour Area")
|
||||
delta_alpha: float = Field(default=0.2, title="Delta Alpha")
|
||||
frame_alpha: float = Field(default=0.01, title="Frame Alpha")
|
||||
frame_height: Optional[int] = Field(default=100, title="Frame Height")
|
||||
mask: Union[str, list[str]] = Field(
|
||||
default="", title="Coordinates polygon for the motion mask."
|
||||
improve_contrast: bool = Field(
|
||||
default=True,
|
||||
title="Improve contrast",
|
||||
description="Apply contrast improvement to frames before motion analysis to help detection.",
|
||||
)
|
||||
contour_area: Optional[int] = Field(
|
||||
default=10,
|
||||
title="Contour area",
|
||||
description="Minimum contour area in pixels required for a motion contour to be counted.",
|
||||
)
|
||||
delta_alpha: float = Field(
|
||||
default=0.2,
|
||||
title="Delta alpha",
|
||||
description="Alpha blending factor used in frame differencing for motion calculation.",
|
||||
)
|
||||
frame_alpha: float = Field(
|
||||
default=0.01,
|
||||
title="Frame alpha",
|
||||
description="Alpha value used when blending frames for motion preprocessing.",
|
||||
)
|
||||
frame_height: Optional[int] = Field(
|
||||
default=100,
|
||||
title="Frame height",
|
||||
description="Height in pixels to scale frames to when computing motion.",
|
||||
)
|
||||
mask: dict[str, Optional[MotionMaskConfig]] = Field(
|
||||
default_factory=dict,
|
||||
title="Mask coordinates",
|
||||
description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.",
|
||||
)
|
||||
mqtt_off_delay: int = Field(
|
||||
default=30,
|
||||
title="Delay for updating MQTT with no motion detected.",
|
||||
title="MQTT off delay",
|
||||
description="Seconds to wait after last motion before publishing an MQTT 'off' state.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of motion detection."
|
||||
default=None,
|
||||
title="Original motion state",
|
||||
description="Indicates whether motion detection was enabled in the original static configuration.",
|
||||
)
|
||||
raw_mask: dict[str, Optional[MotionMaskConfig]] = Field(
|
||||
default_factory=dict, exclude=True
|
||||
)
|
||||
raw_mask: Union[str, list[str]] = ""
|
||||
|
||||
@field_serializer("mask", when_used="json")
|
||||
def serialize_mask(self, value: Any, info):
|
||||
return self.raw_mask
|
||||
if self.raw_mask:
|
||||
return self.raw_mask
|
||||
return value
|
||||
|
||||
@field_serializer("raw_mask", when_used="json")
|
||||
def serialize_raw_mask(self, value: Any, info):
|
||||
|
||||
@@ -6,18 +6,40 @@ __all__ = ["CameraMqttConfig"]
|
||||
|
||||
|
||||
class CameraMqttConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Send image over MQTT.")
|
||||
timestamp: bool = Field(default=True, title="Add timestamp to MQTT image.")
|
||||
bounding_box: bool = Field(default=True, title="Add bounding box to MQTT image.")
|
||||
crop: bool = Field(default=True, title="Crop MQTT image to detected object.")
|
||||
height: int = Field(default=270, title="MQTT image height.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Send image",
|
||||
description="Enable publishing image snapshots for objects to MQTT topics for this camera.",
|
||||
)
|
||||
timestamp: bool = Field(
|
||||
default=True,
|
||||
title="Add timestamp",
|
||||
description="Overlay a timestamp on images published to MQTT.",
|
||||
)
|
||||
bounding_box: bool = Field(
|
||||
default=True,
|
||||
title="Add bounding box",
|
||||
description="Draw bounding boxes on images published over MQTT.",
|
||||
)
|
||||
crop: bool = Field(
|
||||
default=True,
|
||||
title="Crop image",
|
||||
description="Crop images published to MQTT to the detected object's bounding box.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=270,
|
||||
title="Image height",
|
||||
description="Height (pixels) to resize images published over MQTT.",
|
||||
)
|
||||
required_zones: list[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to send the image.",
|
||||
title="Required zones",
|
||||
description="Zones that an object must enter for an MQTT image to be published.",
|
||||
)
|
||||
quality: int = Field(
|
||||
default=70,
|
||||
title="Quality of the encoded jpeg (0-100).",
|
||||
title="JPEG quality",
|
||||
description="JPEG quality for images published to MQTT (0-100).",
|
||||
ge=0,
|
||||
le=100,
|
||||
)
|
||||
|
||||
@@ -8,11 +8,24 @@ __all__ = ["NotificationConfig"]
|
||||
|
||||
|
||||
class NotificationConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable notifications")
|
||||
email: Optional[str] = Field(default=None, title="Email required for push.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
)
|
||||
cooldown: int = Field(
|
||||
default=0, ge=0, title="Cooldown period for notifications (time in seconds)."
|
||||
default=0,
|
||||
ge=0,
|
||||
title="Cooldown period",
|
||||
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of notifications."
|
||||
default=None,
|
||||
title="Original notifications state",
|
||||
description="Indicates whether notifications were enabled in the original static configuration.",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Optional, Union
|
||||
from pydantic import Field, PrivateAttr, field_serializer, field_validator
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from .mask import ObjectMaskConfig
|
||||
|
||||
__all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"]
|
||||
|
||||
@@ -13,36 +14,48 @@ DEFAULT_TRACKED_OBJECTS = ["person"]
|
||||
class FilterConfig(FrigateBaseModel):
|
||||
min_area: Union[int, float] = Field(
|
||||
default=0,
|
||||
title="Minimum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).",
|
||||
title="Minimum object area",
|
||||
description="Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).",
|
||||
)
|
||||
max_area: Union[int, float] = Field(
|
||||
default=24000000,
|
||||
title="Maximum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99).",
|
||||
title="Maximum object area",
|
||||
description="Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).",
|
||||
)
|
||||
min_ratio: float = Field(
|
||||
default=0,
|
||||
title="Minimum ratio of bounding box's width/height for object to be counted.",
|
||||
title="Minimum aspect ratio",
|
||||
description="Minimum width/height ratio required for the bounding box to qualify.",
|
||||
)
|
||||
max_ratio: float = Field(
|
||||
default=24000000,
|
||||
title="Maximum ratio of bounding box's width/height for object to be counted.",
|
||||
title="Maximum aspect ratio",
|
||||
description="Maximum width/height ratio allowed for the bounding box to qualify.",
|
||||
)
|
||||
threshold: float = Field(
|
||||
default=0.7,
|
||||
title="Average detection confidence threshold for object to be counted.",
|
||||
title="Confidence threshold",
|
||||
description="Average detection confidence threshold required for the object to be considered a true positive.",
|
||||
)
|
||||
min_score: float = Field(
|
||||
default=0.5, title="Minimum detection confidence for object to be counted."
|
||||
default=0.5,
|
||||
title="Minimum confidence",
|
||||
description="Minimum single-frame detection confidence required for the object to be counted.",
|
||||
)
|
||||
mask: Optional[Union[str, list[str]]] = Field(
|
||||
default=None,
|
||||
title="Detection area polygon mask for this filter configuration.",
|
||||
mask: dict[str, Optional[ObjectMaskConfig]] = Field(
|
||||
default_factory=dict,
|
||||
title="Filter mask",
|
||||
description="Polygon coordinates defining where this filter applies within the frame.",
|
||||
)
|
||||
raw_mask: dict[str, Optional[ObjectMaskConfig]] = Field(
|
||||
default_factory=dict, exclude=True
|
||||
)
|
||||
raw_mask: Union[str, list[str]] = ""
|
||||
|
||||
@field_serializer("mask", when_used="json")
|
||||
def serialize_mask(self, value: Any, info):
|
||||
return self.raw_mask
|
||||
if self.raw_mask:
|
||||
return self.raw_mask
|
||||
return value
|
||||
|
||||
@field_serializer("raw_mask", when_used="json")
|
||||
def serialize_raw_mask(self, value: Any, info):
|
||||
@@ -51,46 +64,64 @@ class FilterConfig(FrigateBaseModel):
|
||||
|
||||
class GenAIObjectTriggerConfig(FrigateBaseModel):
|
||||
tracked_object_end: bool = Field(
|
||||
default=True, title="Send once the object is no longer tracked."
|
||||
default=True,
|
||||
title="Send on end",
|
||||
description="Send a request to GenAI when the tracked object ends.",
|
||||
)
|
||||
after_significant_updates: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Send an early request to generative AI when X frames accumulated.",
|
||||
title="Early GenAI trigger",
|
||||
description="Send a request to GenAI after a specified number of significant updates for the tracked object.",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class GenAIObjectConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable GenAI for camera.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable GenAI",
|
||||
description="Enable GenAI generation of descriptions for tracked objects by default.",
|
||||
)
|
||||
use_snapshot: bool = Field(
|
||||
default=False, title="Use snapshots for generating descriptions."
|
||||
default=False,
|
||||
title="Use snapshots",
|
||||
description="Use object snapshots instead of thumbnails for GenAI description generation.",
|
||||
)
|
||||
prompt: str = Field(
|
||||
default="Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.",
|
||||
title="Default caption prompt.",
|
||||
title="Caption prompt",
|
||||
description="Default prompt template used when generating descriptions with GenAI.",
|
||||
)
|
||||
object_prompts: dict[str, str] = Field(
|
||||
default_factory=dict, title="Object specific prompts."
|
||||
default_factory=dict,
|
||||
title="Object prompts",
|
||||
description="Per-object prompts to customize GenAI outputs for specific labels.",
|
||||
)
|
||||
|
||||
objects: Union[str, list[str]] = Field(
|
||||
default_factory=list,
|
||||
title="List of objects to run generative AI for.",
|
||||
title="GenAI objects",
|
||||
description="List of object labels to send to GenAI by default.",
|
||||
)
|
||||
required_zones: Union[str, list[str]] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to run generative AI.",
|
||||
title="Required zones",
|
||||
description="Zones that must be entered for objects to qualify for GenAI description generation.",
|
||||
)
|
||||
debug_save_thumbnails: bool = Field(
|
||||
default=False,
|
||||
title="Save thumbnails sent to generative AI for debugging purposes.",
|
||||
title="Save thumbnails",
|
||||
description="Save thumbnails sent to GenAI for debugging and review.",
|
||||
)
|
||||
send_triggers: GenAIObjectTriggerConfig = Field(
|
||||
default_factory=GenAIObjectTriggerConfig,
|
||||
title="What triggers to use to send frames to generative AI for a tracked object.",
|
||||
title="GenAI triggers",
|
||||
description="Defines when frames should be sent to GenAI (on end, after updates, etc.).",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of generative AI."
|
||||
default=None,
|
||||
title="Original GenAI state",
|
||||
description="Indicates whether GenAI was enabled in the original static config.",
|
||||
)
|
||||
|
||||
@field_validator("required_zones", mode="before")
|
||||
@@ -103,14 +134,28 @@ class GenAIObjectConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class ObjectConfig(FrigateBaseModel):
|
||||
track: list[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||
filters: dict[str, FilterConfig] = Field(
|
||||
default_factory=dict, title="Object filters."
|
||||
track: list[str] = Field(
|
||||
default=DEFAULT_TRACKED_OBJECTS,
|
||||
title="Objects to track",
|
||||
description="List of object labels to track for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
filters: dict[str, FilterConfig] = Field(
|
||||
default_factory=dict,
|
||||
title="Object filters",
|
||||
description="Filters applied to detected objects to reduce false positives (area, ratio, confidence).",
|
||||
)
|
||||
mask: dict[str, Optional[ObjectMaskConfig]] = Field(
|
||||
default_factory=dict,
|
||||
title="Object mask",
|
||||
description="Mask polygon used to prevent object detection in specified areas.",
|
||||
)
|
||||
raw_mask: dict[str, Optional[ObjectMaskConfig]] = Field(
|
||||
default_factory=dict, exclude=True
|
||||
)
|
||||
mask: Union[str, list[str]] = Field(default="", title="Object mask.")
|
||||
genai: GenAIObjectConfig = Field(
|
||||
default_factory=GenAIObjectConfig,
|
||||
title="Config for using genai to analyze objects.",
|
||||
title="GenAI object config",
|
||||
description="GenAI options for describing tracked objects and sending frames for generation.",
|
||||
)
|
||||
_all_objects: list[str] = PrivateAttr()
|
||||
|
||||
@@ -129,3 +174,13 @@ class ObjectConfig(FrigateBaseModel):
|
||||
enabled_labels.update(camera.objects.track)
|
||||
|
||||
self._all_objects = list(enabled_labels)
|
||||
|
||||
@field_serializer("mask", when_used="json")
|
||||
def serialize_mask(self, value: Any, info):
|
||||
if self.raw_mask:
|
||||
return self.raw_mask
|
||||
return value
|
||||
|
||||
@field_serializer("raw_mask", when_used="json")
|
||||
def serialize_raw_mask(self, value: Any, info):
|
||||
return None
|
||||
|
||||
@@ -17,37 +17,57 @@ class ZoomingModeEnum(str, Enum):
|
||||
|
||||
|
||||
class PtzAutotrackConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable PTZ object autotracking.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable Autotracking",
|
||||
description="Enable or disable automatic PTZ camera tracking of detected objects.",
|
||||
)
|
||||
calibrate_on_startup: bool = Field(
|
||||
default=False, title="Perform a camera calibration when Frigate starts."
|
||||
default=False,
|
||||
title="Calibrate on start",
|
||||
description="Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.",
|
||||
)
|
||||
zooming: ZoomingModeEnum = Field(
|
||||
default=ZoomingModeEnum.disabled, title="Autotracker zooming mode."
|
||||
default=ZoomingModeEnum.disabled,
|
||||
title="Zoom mode",
|
||||
description="Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).",
|
||||
)
|
||||
zoom_factor: float = Field(
|
||||
default=0.3,
|
||||
title="Zooming factor (0.1-0.75).",
|
||||
title="Zoom factor",
|
||||
description="Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.",
|
||||
ge=0.1,
|
||||
le=0.75,
|
||||
)
|
||||
track: list[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.")
|
||||
track: list[str] = Field(
|
||||
default=DEFAULT_TRACKED_OBJECTS,
|
||||
title="Tracked objects",
|
||||
description="List of object types that should trigger autotracking.",
|
||||
)
|
||||
required_zones: list[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to begin autotracking.",
|
||||
title="Required zones",
|
||||
description="Objects must enter one of these zones before autotracking begins.",
|
||||
)
|
||||
return_preset: str = Field(
|
||||
default="home",
|
||||
title="Name of camera preset to return to when object tracking is over.",
|
||||
title="Return preset",
|
||||
description="ONVIF preset name configured in camera firmware to return to after tracking ends.",
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=10, title="Seconds to delay before returning to preset."
|
||||
default=10,
|
||||
title="Return timeout",
|
||||
description="Wait this many seconds after losing tracking before returning camera to preset position.",
|
||||
)
|
||||
movement_weights: Optional[Union[str, list[str]]] = Field(
|
||||
default_factory=list,
|
||||
title="Internal value used for PTZ movements based on the speed of your camera's motor.",
|
||||
title="Movement weights",
|
||||
description="Calibration values automatically generated by camera calibration. Do not modify manually.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of autotracking."
|
||||
default=None,
|
||||
title="Original autotrack state",
|
||||
description="Internal field to track whether autotracking was enabled in configuration.",
|
||||
)
|
||||
|
||||
@field_validator("movement_weights", mode="before")
|
||||
@@ -72,16 +92,38 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
host: str = Field(default="", title="Onvif Host")
|
||||
port: int = Field(default=8000, title="Onvif Port")
|
||||
user: Optional[EnvString] = Field(default=None, title="Onvif Username")
|
||||
password: Optional[EnvString] = Field(default=None, title="Onvif Password")
|
||||
tls_insecure: bool = Field(default=False, title="Onvif Disable TLS verification")
|
||||
host: str = Field(
|
||||
default="",
|
||||
title="ONVIF host",
|
||||
description="Host (and optional scheme) for the ONVIF service for this camera.",
|
||||
)
|
||||
port: int = Field(
|
||||
default=8000,
|
||||
title="ONVIF port",
|
||||
description="Port number for the ONVIF service.",
|
||||
)
|
||||
user: Optional[EnvString] = Field(
|
||||
default=None,
|
||||
title="ONVIF username",
|
||||
description="Username for ONVIF authentication; some devices require admin user for ONVIF.",
|
||||
)
|
||||
password: Optional[EnvString] = Field(
|
||||
default=None,
|
||||
title="ONVIF password",
|
||||
description="Password for ONVIF authentication.",
|
||||
)
|
||||
tls_insecure: bool = Field(
|
||||
default=False,
|
||||
title="Disable TLS verify",
|
||||
description="Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).",
|
||||
)
|
||||
autotracking: PtzAutotrackConfig = Field(
|
||||
default_factory=PtzAutotrackConfig,
|
||||
title="PTZ auto tracking config.",
|
||||
title="Autotracking",
|
||||
description="Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||
)
|
||||
ignore_time_mismatch: bool = Field(
|
||||
default=False,
|
||||
title="Onvif Ignore Time Synchronization Mismatch Between Camera and Server",
|
||||
title="Ignore time mismatch",
|
||||
description="Ignore time synchronization differences between camera and Frigate server for ONVIF communication.",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@@ -19,11 +19,14 @@ __all__ = [
|
||||
"RetainModeEnum",
|
||||
]
|
||||
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
|
||||
|
||||
class RecordRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=0, ge=0, title="Default retention period.")
|
||||
days: float = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
title="Retention days",
|
||||
description="Days to retain recordings.",
|
||||
)
|
||||
|
||||
|
||||
class RetainModeEnum(str, Enum):
|
||||
@@ -33,22 +36,37 @@ class RetainModeEnum(str, Enum):
|
||||
|
||||
|
||||
class ReviewRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=10, ge=0, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||
days: float = Field(
|
||||
default=10,
|
||||
ge=0,
|
||||
title="Retention days",
|
||||
description="Number of days to retain recordings of detection events.",
|
||||
)
|
||||
mode: RetainModeEnum = Field(
|
||||
default=RetainModeEnum.motion,
|
||||
title="Retention mode",
|
||||
description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).",
|
||||
)
|
||||
|
||||
|
||||
class EventsConfig(FrigateBaseModel):
|
||||
pre_capture: int = Field(
|
||||
default=5,
|
||||
title="Seconds to retain before event starts.",
|
||||
title="Pre-capture seconds",
|
||||
description="Number of seconds before the detection event to include in the recording.",
|
||||
le=MAX_PRE_CAPTURE,
|
||||
ge=0,
|
||||
)
|
||||
post_capture: int = Field(
|
||||
default=5, ge=0, title="Seconds to retain after event ends."
|
||||
default=5,
|
||||
ge=0,
|
||||
title="Post-capture seconds",
|
||||
description="Number of seconds after the detection event to include in the recording.",
|
||||
)
|
||||
retain: ReviewRetainConfig = Field(
|
||||
default_factory=ReviewRetainConfig, title="Event retention settings."
|
||||
default_factory=ReviewRetainConfig,
|
||||
title="Event retention",
|
||||
description="Retention settings for recordings of detection events.",
|
||||
)
|
||||
|
||||
|
||||
@@ -62,46 +80,65 @@ class RecordQualityEnum(str, Enum):
|
||||
|
||||
class RecordPreviewConfig(FrigateBaseModel):
|
||||
quality: RecordQualityEnum = Field(
|
||||
default=RecordQualityEnum.medium, title="Quality of recording preview."
|
||||
default=RecordQualityEnum.medium,
|
||||
title="Preview quality",
|
||||
description="Preview quality level (very_low, low, medium, high, very_high).",
|
||||
)
|
||||
|
||||
|
||||
class RecordExportConfig(FrigateBaseModel):
|
||||
timelapse_args: str = Field(
|
||||
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
|
||||
hwaccel_args: Union[str, list[str]] = Field(
|
||||
default="auto",
|
||||
title="Export hwaccel args",
|
||||
description="Hardware acceleration args to use for export/transcode operations.",
|
||||
)
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable record on all cameras.")
|
||||
sync_recordings: bool = Field(
|
||||
default=False, title="Sync recordings with disk on startup and once a day."
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable recording",
|
||||
description="Enable or disable recording for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
expire_interval: int = Field(
|
||||
default=60,
|
||||
title="Number of minutes to wait between cleanup runs.",
|
||||
title="Record cleanup interval",
|
||||
description="Minutes between cleanup passes that remove expired recording segments.",
|
||||
)
|
||||
continuous: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig,
|
||||
title="Continuous recording retention settings.",
|
||||
title="Continuous retention",
|
||||
description="Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.",
|
||||
)
|
||||
motion: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig, title="Motion recording retention settings."
|
||||
default_factory=RecordRetainConfig,
|
||||
title="Motion retention",
|
||||
description="Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.",
|
||||
)
|
||||
detections: EventsConfig = Field(
|
||||
default_factory=EventsConfig, title="Detection specific retention settings."
|
||||
default_factory=EventsConfig,
|
||||
title="Detection retention",
|
||||
description="Recording retention settings for detection events including pre/post capture durations.",
|
||||
)
|
||||
alerts: EventsConfig = Field(
|
||||
default_factory=EventsConfig, title="Alert specific retention settings."
|
||||
default_factory=EventsConfig,
|
||||
title="Alert retention",
|
||||
description="Recording retention settings for alert events including pre/post capture durations.",
|
||||
)
|
||||
export: RecordExportConfig = Field(
|
||||
default_factory=RecordExportConfig, title="Recording Export Config"
|
||||
default_factory=RecordExportConfig,
|
||||
title="Export config",
|
||||
description="Settings used when exporting recordings such as timelapse and hardware acceleration.",
|
||||
)
|
||||
preview: RecordPreviewConfig = Field(
|
||||
default_factory=RecordPreviewConfig, title="Recording Preview Config"
|
||||
default_factory=RecordPreviewConfig,
|
||||
title="Preview config",
|
||||
description="Settings controlling the quality of recording previews shown in the UI.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of recording."
|
||||
default=None,
|
||||
title="Original recording state",
|
||||
description="Indicates whether recording was enabled in the original static configuration.",
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -21,22 +21,32 @@ DEFAULT_ALERT_OBJECTS = ["person", "car"]
|
||||
class AlertsConfig(FrigateBaseModel):
|
||||
"""Configure alerts"""
|
||||
|
||||
enabled: bool = Field(default=True, title="Enable alerts.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable alerts",
|
||||
description="Enable or disable alert generation for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
|
||||
labels: list[str] = Field(
|
||||
default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for."
|
||||
default=DEFAULT_ALERT_OBJECTS,
|
||||
title="Alert labels",
|
||||
description="List of object labels that qualify as alerts (for example: car, person).",
|
||||
)
|
||||
required_zones: Union[str, list[str]] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save the event as an alert.",
|
||||
title="Required zones",
|
||||
description="Zones that an object must enter to be considered an alert; leave empty to allow any zone.",
|
||||
)
|
||||
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of alerts."
|
||||
default=None,
|
||||
title="Original alerts state",
|
||||
description="Tracks whether alerts were originally enabled in the static configuration.",
|
||||
)
|
||||
cutoff_time: int = Field(
|
||||
default=40,
|
||||
title="Time to cutoff alerts after no alert-causing activity has occurred.",
|
||||
title="Alerts cutoff time",
|
||||
description="Seconds to wait after no alert-causing activity before cutting off an alert.",
|
||||
)
|
||||
|
||||
@field_validator("required_zones", mode="before")
|
||||
@@ -51,22 +61,32 @@ class AlertsConfig(FrigateBaseModel):
|
||||
class DetectionsConfig(FrigateBaseModel):
|
||||
"""Configure detections"""
|
||||
|
||||
enabled: bool = Field(default=True, title="Enable detections.")
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
title="Enable detections",
|
||||
description="Enable or disable detection events for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
|
||||
labels: Optional[list[str]] = Field(
|
||||
default=None, title="Labels to create detections for."
|
||||
default=None,
|
||||
title="Detection labels",
|
||||
description="List of object labels that qualify as detection events.",
|
||||
)
|
||||
required_zones: Union[str, list[str]] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save the event as a detection.",
|
||||
title="Required zones",
|
||||
description="Zones that an object must enter to be considered a detection; leave empty to allow any zone.",
|
||||
)
|
||||
cutoff_time: int = Field(
|
||||
default=30,
|
||||
title="Time to cutoff detection after no detection-causing activity has occurred.",
|
||||
title="Detections cutoff time",
|
||||
description="Seconds to wait after no detection-causing activity before cutting off a detection.",
|
||||
)
|
||||
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of detections."
|
||||
default=None,
|
||||
title="Original detections state",
|
||||
description="Tracks whether detections were originally enabled in the static configuration.",
|
||||
)
|
||||
|
||||
@field_validator("required_zones", mode="before")
|
||||
@@ -81,39 +101,55 @@ class DetectionsConfig(FrigateBaseModel):
|
||||
class GenAIReviewConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable GenAI descriptions for review items.",
|
||||
title="Enable GenAI descriptions",
|
||||
description="Enable or disable GenAI-generated descriptions and summaries for review items.",
|
||||
)
|
||||
alerts: bool = Field(
|
||||
default=True,
|
||||
title="Enable GenAI for alerts",
|
||||
description="Use GenAI to generate descriptions for alert items.",
|
||||
)
|
||||
detections: bool = Field(
|
||||
default=False,
|
||||
title="Enable GenAI for detections",
|
||||
description="Use GenAI to generate descriptions for detection items.",
|
||||
)
|
||||
alerts: bool = Field(default=True, title="Enable GenAI for alerts.")
|
||||
detections: bool = Field(default=False, title="Enable GenAI for detections.")
|
||||
image_source: ImageSourceEnum = Field(
|
||||
default=ImageSourceEnum.preview,
|
||||
title="Image source for review descriptions.",
|
||||
title="Review image source",
|
||||
description="Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.",
|
||||
)
|
||||
additional_concerns: list[str] = Field(
|
||||
default=[],
|
||||
title="Additional concerns that GenAI should make note of on this camera.",
|
||||
title="Additional concerns",
|
||||
description="A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.",
|
||||
)
|
||||
debug_save_thumbnails: bool = Field(
|
||||
default=False,
|
||||
title="Save thumbnails sent to generative AI for debugging purposes.",
|
||||
title="Save thumbnails",
|
||||
description="Save thumbnails that are sent to the GenAI provider for debugging and review.",
|
||||
)
|
||||
enabled_in_config: Optional[bool] = Field(
|
||||
default=None, title="Keep track of original state of generative AI."
|
||||
default=None,
|
||||
title="Original GenAI state",
|
||||
description="Tracks whether GenAI review was originally enabled in the static configuration.",
|
||||
)
|
||||
preferred_language: str | None = Field(
|
||||
title="Preferred language for GenAI Response",
|
||||
title="Preferred language",
|
||||
description="Preferred language to request from the GenAI provider for generated responses.",
|
||||
default=None,
|
||||
)
|
||||
activity_context_prompt: str = Field(
|
||||
default="""### Normal Activity Indicators (Level 0)
|
||||
- Known/verified people in any zone at any time
|
||||
- People with pets in residential areas
|
||||
- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items — normal commute and travel patterns
|
||||
- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving
|
||||
- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime
|
||||
- Activity confined to public areas only (sidewalks, streets) without entering property at any time
|
||||
|
||||
### Suspicious Activity Indicators (Level 1)
|
||||
- **Testing or attempting to open doors/windows/handles on vehicles or buildings** — ALWAYS Level 1 regardless of time or duration
|
||||
- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools — Level 1
|
||||
- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** — ALWAYS Level 1 regardless of activity or duration
|
||||
- Taking items that don't belong to them (packages, objects from porches/driveways)
|
||||
- Climbing or jumping fences/barriers to access property
|
||||
@@ -133,24 +169,29 @@ Evaluate in this order:
|
||||
1. **If person is verified/known** → Level 0 regardless of time or activity
|
||||
2. **If person is unidentified:**
|
||||
- Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) → Level 1
|
||||
- Check actions: If testing doors/handles, taking items, climbing → Level 1
|
||||
- Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service worker) → Level 0
|
||||
- Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing → Level 1
|
||||
- Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) → Level 0
|
||||
3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)
|
||||
|
||||
The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""",
|
||||
title="Custom activity context prompt defining normal and suspicious activity patterns for this property.",
|
||||
title="Activity context prompt",
|
||||
description="Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.",
|
||||
)
|
||||
|
||||
|
||||
class ReviewConfig(FrigateBaseModel):
|
||||
"""Configure reviews"""
|
||||
|
||||
alerts: AlertsConfig = Field(
|
||||
default_factory=AlertsConfig, title="Review alerts config."
|
||||
default_factory=AlertsConfig,
|
||||
title="Alerts config",
|
||||
description="Settings for which tracked objects generate alerts and how alerts are retained.",
|
||||
)
|
||||
detections: DetectionsConfig = Field(
|
||||
default_factory=DetectionsConfig, title="Review detections config."
|
||||
default_factory=DetectionsConfig,
|
||||
title="Detections config",
|
||||
description="Settings for creating detection events (non-alert) and how long to keep them.",
|
||||
)
|
||||
genai: GenAIReviewConfig = Field(
|
||||
default_factory=GenAIReviewConfig, title="Review description genai config."
|
||||
default_factory=GenAIReviewConfig,
|
||||
title="GenAI config",
|
||||
description="Controls use of generative AI for producing descriptions and summaries of review items.",
|
||||
)
|
||||
|
||||
@@ -9,36 +9,68 @@ __all__ = ["SnapshotsConfig", "RetainConfig"]
|
||||
|
||||
|
||||
class RetainConfig(FrigateBaseModel):
|
||||
default: float = Field(default=10, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||
default: float = Field(
|
||||
default=10,
|
||||
title="Default retention",
|
||||
description="Default number of days to retain snapshots.",
|
||||
)
|
||||
mode: RetainModeEnum = Field(
|
||||
default=RetainModeEnum.motion,
|
||||
title="Retention mode",
|
||||
description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).",
|
||||
)
|
||||
objects: dict[str, float] = Field(
|
||||
default_factory=dict, title="Object retention period."
|
||||
default_factory=dict,
|
||||
title="Object retention",
|
||||
description="Per-object overrides for snapshot retention days.",
|
||||
)
|
||||
|
||||
|
||||
class SnapshotsConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Snapshots enabled.")
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Snapshots enabled",
|
||||
description="Enable or disable saving snapshots for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
clean_copy: bool = Field(
|
||||
default=True, title="Create a clean copy of the snapshot image."
|
||||
default=True,
|
||||
title="Save clean copy",
|
||||
description="Save an unannotated clean copy of snapshots in addition to annotated ones.",
|
||||
)
|
||||
timestamp: bool = Field(
|
||||
default=False, title="Add a timestamp overlay on the snapshot."
|
||||
default=False,
|
||||
title="Timestamp overlay",
|
||||
description="Overlay a timestamp on saved snapshots.",
|
||||
)
|
||||
bounding_box: bool = Field(
|
||||
default=True, title="Add a bounding box overlay on the snapshot."
|
||||
default=True,
|
||||
title="Bounding box overlay",
|
||||
description="Draw bounding boxes for tracked objects on saved snapshots.",
|
||||
)
|
||||
crop: bool = Field(
|
||||
default=False,
|
||||
title="Crop snapshot",
|
||||
description="Crop saved snapshots to the detected object's bounding box.",
|
||||
)
|
||||
crop: bool = Field(default=False, title="Crop the snapshot to the detected object.")
|
||||
required_zones: list[str] = Field(
|
||||
default_factory=list,
|
||||
title="List of required zones to be entered in order to save a snapshot.",
|
||||
title="Required zones",
|
||||
description="Zones an object must enter for a snapshot to be saved.",
|
||||
)
|
||||
height: Optional[int] = Field(
|
||||
default=None,
|
||||
title="Snapshot height",
|
||||
description="Height (pixels) to resize saved snapshots to; leave empty to preserve original size.",
|
||||
)
|
||||
height: Optional[int] = Field(default=None, title="Snapshot image height.")
|
||||
retain: RetainConfig = Field(
|
||||
default_factory=RetainConfig, title="Snapshot retention."
|
||||
default_factory=RetainConfig,
|
||||
title="Snapshot retention",
|
||||
description="Retention settings for saved snapshots including default days and per-object overrides.",
|
||||
)
|
||||
quality: int = Field(
|
||||
default=70,
|
||||
title="Quality of the encoded jpeg (0-100).",
|
||||
title="JPEG quality",
|
||||
description="JPEG encode quality for saved snapshots (0-100).",
|
||||
ge=0,
|
||||
le=100,
|
||||
)
|
||||
|
||||
@@ -27,9 +27,27 @@ class TimestampPositionEnum(str, Enum):
|
||||
|
||||
|
||||
class ColorConfig(FrigateBaseModel):
|
||||
red: int = Field(default=255, ge=0, le=255, title="Red")
|
||||
green: int = Field(default=255, ge=0, le=255, title="Green")
|
||||
blue: int = Field(default=255, ge=0, le=255, title="Blue")
|
||||
red: int = Field(
|
||||
default=255,
|
||||
ge=0,
|
||||
le=255,
|
||||
title="Red",
|
||||
description="Red component (0-255) for timestamp color.",
|
||||
)
|
||||
green: int = Field(
|
||||
default=255,
|
||||
ge=0,
|
||||
le=255,
|
||||
title="Green",
|
||||
description="Green component (0-255) for timestamp color.",
|
||||
)
|
||||
blue: int = Field(
|
||||
default=255,
|
||||
ge=0,
|
||||
le=255,
|
||||
title="Blue",
|
||||
description="Blue component (0-255) for timestamp color.",
|
||||
)
|
||||
|
||||
|
||||
class TimestampEffectEnum(str, Enum):
|
||||
@@ -39,11 +57,27 @@ class TimestampEffectEnum(str, Enum):
|
||||
|
||||
class TimestampStyleConfig(FrigateBaseModel):
|
||||
position: TimestampPositionEnum = Field(
|
||||
default=TimestampPositionEnum.tl, title="Timestamp position."
|
||||
default=TimestampPositionEnum.tl,
|
||||
title="Timestamp position",
|
||||
description="Position of the timestamp on the image (tl/tr/bl/br).",
|
||||
)
|
||||
format: str = Field(
|
||||
default=DEFAULT_TIME_FORMAT,
|
||||
title="Timestamp format",
|
||||
description="Datetime format string used for timestamps (Python datetime format codes).",
|
||||
)
|
||||
color: ColorConfig = Field(
|
||||
default_factory=ColorConfig,
|
||||
title="Timestamp color",
|
||||
description="RGB color values for the timestamp text (all values 0-255).",
|
||||
)
|
||||
thickness: int = Field(
|
||||
default=2,
|
||||
title="Timestamp thickness",
|
||||
description="Line thickness of the timestamp text.",
|
||||
)
|
||||
format: str = Field(default=DEFAULT_TIME_FORMAT, title="Timestamp format.")
|
||||
color: ColorConfig = Field(default_factory=ColorConfig, title="Timestamp color.")
|
||||
thickness: int = Field(default=2, title="Timestamp thickness.")
|
||||
effect: Optional[TimestampEffectEnum] = Field(
|
||||
default=None, title="Timestamp effect."
|
||||
default=None,
|
||||
title="Timestamp effect",
|
||||
description="Visual effect for the timestamp text (none, solid, shadow).",
|
||||
)
|
||||
|
||||
@@ -6,7 +6,13 @@ __all__ = ["CameraUiConfig"]
|
||||
|
||||
|
||||
class CameraUiConfig(FrigateBaseModel):
|
||||
order: int = Field(default=0, title="Order of camera in UI.")
|
||||
dashboard: bool = Field(
|
||||
default=True, title="Show this camera in Frigate dashboard UI."
|
||||
order: int = Field(
|
||||
default=0,
|
||||
title="UI order",
|
||||
description="Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.",
|
||||
)
|
||||
dashboard: bool = Field(
|
||||
default=True,
|
||||
title="Show in UI",
|
||||
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user